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

import com.carrotsearch.randomizedtesting.generators.RandomNumbers;
import com.carrotsearch.randomizedtesting.generators.RandomPicks;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Random;
import java.util.Set;
import java.util.SortedSet;
import java.util.TreeSet;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.DoubleDocValuesField;
import org.apache.lucene.document.DoublePoint;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.FloatDocValuesField;
import org.apache.lucene.document.FloatPoint;
import org.apache.lucene.document.IntPoint;
import org.apache.lucene.document.LongPoint;
import org.apache.lucene.document.NumericDocValuesField;
import org.apache.lucene.document.SortedDocValuesField;
import org.apache.lucene.document.SortedNumericDocValuesField;
import org.apache.lucene.document.SortedSetDocValuesField;
import org.apache.lucene.document.StringField;
import org.apache.lucene.document.TextField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.DocValues;
import org.apache.lucene.index.IndexReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.LeafReader;
import org.apache.lucene.index.LeafReaderContext;
import org.apache.lucene.index.MultiTerms;
import org.apache.lucene.index.NoMergePolicy;
import org.apache.lucene.index.NumericDocValues;
import org.apache.lucene.index.OrdinalMap;
import org.apache.lucene.index.PostingsEnum;
import org.apache.lucene.index.SortedDocValues;
import org.apache.lucene.index.SortedSetDocValues;
import org.apache.lucene.index.Term;
import org.apache.lucene.index.Terms;
import org.apache.lucene.index.TermsEnum;
import org.apache.lucene.search.BooleanClause;
import org.apache.lucene.search.BooleanQuery;
import org.apache.lucene.search.BoostQuery;
import org.apache.lucene.search.CollectorManager;
import org.apache.lucene.search.DocIdSetIterator;
import org.apache.lucene.search.Explanation;
import org.apache.lucene.search.FieldExistsQuery;
import org.apache.lucene.search.FilterScorer;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.MatchAllDocsQuery;
import org.apache.lucene.search.MatchNoDocsQuery;
import org.apache.lucene.search.MultiCollectorManager;
import org.apache.lucene.search.Query;
import org.apache.lucene.search.QueryVisitor;
import org.apache.lucene.search.Scorable;
import org.apache.lucene.search.ScoreDoc;
import org.apache.lucene.search.Scorer;
import org.apache.lucene.search.ScorerSupplier;
import org.apache.lucene.search.SimpleCollector;
import org.apache.lucene.search.TermQuery;
import org.apache.lucene.search.TopDocs;
import org.apache.lucene.search.TopScoreDocCollectorManager;
import org.apache.lucene.search.TotalHits;
import org.apache.lucene.search.Weight;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.analysis.MockAnalyzer;
import org.apache.lucene.tests.analysis.MockTokenizer;
import org.apache.lucene.tests.index.RandomIndexWriter;
import org.apache.lucene.tests.search.QueryUtils;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;
import org.apache.lucene.util.BitSet;
import org.apache.lucene.util.BitSetIterator;
import org.apache.lucene.util.BytesRef;
import org.apache.lucene.util.FixedBitSet;
import org.apache.lucene.util.packed.PackedInts;
import org.junit.Test;

public class TestJoinUtil extends LuceneTestCase {

  public void testSimple() throws Exception {
    final String idField = "id";
    final String toField = "productId";

    Directory dir = newDirectory();
    RandomIndexWriter w =
        new RandomIndexWriter(
            random(),
            dir,
            newIndexWriterConfig(new MockAnalyzer(random())).setMergePolicy(newLogMergePolicy()));

    // 0
    Document doc = new Document();
    doc.add(new TextField("description", "random text", Field.Store.NO));
    doc.add(new TextField("name", "name1", Field.Store.NO));
    doc.add(new TextField(idField, "1", Field.Store.NO));
    doc.add(new SortedDocValuesField(idField, new BytesRef("1")));
    w.addDocument(doc);

    // 1
    doc = new Document();
    doc.add(new TextField("price", "10.0", Field.Store.NO));
    doc.add(new TextField(idField, "2", Field.Store.NO));
    doc.add(new SortedDocValuesField(idField, new BytesRef("2")));
    doc.add(new TextField(toField, "1", Field.Store.NO));
    doc.add(new SortedDocValuesField(toField, new BytesRef("1")));
    w.addDocument(doc);

    // 2
    doc = new Document();
    doc.add(new TextField("price", "20.0", Field.Store.NO));
    doc.add(new TextField(idField, "3", Field.Store.NO));
    doc.add(new SortedDocValuesField(idField, new BytesRef("3")));
    doc.add(new TextField(toField, "1", Field.Store.NO));
    doc.add(new SortedDocValuesField(toField, new BytesRef("1")));
    w.addDocument(doc);

    // 3
    doc = new Document();
    doc.add(new TextField("description", "more random text", Field.Store.NO));
    doc.add(new TextField("name", "name2", Field.Store.NO));
    doc.add(new TextField(idField, "4", Field.Store.NO));
    doc.add(new SortedDocValuesField(idField, new BytesRef("4")));
    w.addDocument(doc);
    w.commit();

    // 4
    doc = new Document();
    doc.add(new TextField("price", "10.0", Field.Store.NO));
    doc.add(new TextField(idField, "5", Field.Store.NO));
    doc.add(new SortedDocValuesField(idField, new BytesRef("5")));
    doc.add(new TextField(toField, "4", Field.Store.NO));
    doc.add(new SortedDocValuesField(toField, new BytesRef("4")));
    w.addDocument(doc);

    // 5
    doc = new Document();
    doc.add(new TextField("price", "20.0", Field.Store.NO));
    doc.add(new TextField(idField, "6", Field.Store.NO));
    doc.add(new SortedDocValuesField(idField, new BytesRef("6")));
    doc.add(new TextField(toField, "4", Field.Store.NO));
    doc.add(new SortedDocValuesField(toField, new BytesRef("4")));
    w.addDocument(doc);

    IndexSearcher indexSearcher = new IndexSearcher(w.getReader());
    w.close();

    // Search for product
    Query joinQuery =
        JoinUtil.createJoinQuery(
            idField,
            false,
            toField,
            new TermQuery(new Term("name", "name2")),
            indexSearcher,
            ScoreMode.None);

    TopDocs result = indexSearcher.search(joinQuery, 10);
    assertEquals(2, result.totalHits.value());
    assertEquals(4, result.scoreDocs[0].doc);
    assertEquals(5, result.scoreDocs[1].doc);

    joinQuery =
        JoinUtil.createJoinQuery(
            idField,
            false,
            toField,
            new TermQuery(new Term("name", "name1")),
            indexSearcher,
            ScoreMode.None);
    result = indexSearcher.search(joinQuery, 10);
    assertEquals(2, result.totalHits.value());
    assertEquals(1, result.scoreDocs[0].doc);
    assertEquals(2, result.scoreDocs[1].doc);

    // Search for offer
    joinQuery =
        JoinUtil.createJoinQuery(
            toField,
            false,
            idField,
            new TermQuery(new Term("id", "5")),
            indexSearcher,
            ScoreMode.None);
    result = indexSearcher.search(joinQuery, 10);
    assertEquals(1, result.totalHits.value());
    assertEquals(3, result.scoreDocs[0].doc);

    indexSearcher.getIndexReader().close();
    dir.close();
  }

  public void testSimpleOrdinalsJoin() throws Exception {
    final String idField = "id";
    final String productIdField = "productId";
    // A field indicating to what type a document belongs, which is then used to distinguish between
    // documents during joining.
    final String typeField = "type";
    // A single sorted doc values field that holds the join values for all document types.
    // Typically during indexing a schema will automatically create this field with the values
    final String joinField = idField + productIdField;

    Directory dir = newDirectory();
    RandomIndexWriter w =
        new RandomIndexWriter(
            random(),
            dir,
            newIndexWriterConfig(new MockAnalyzer(random()))
                .setMergePolicy(NoMergePolicy.INSTANCE));

    // 0
    Document doc = new Document();
    doc.add(new TextField(idField, "1", Field.Store.NO));
    doc.add(new TextField(typeField, "product", Field.Store.NO));
    doc.add(new TextField("description", "random text", Field.Store.NO));
    doc.add(new TextField("name", "name1", Field.Store.NO));
    doc.add(new SortedDocValuesField(joinField, new BytesRef("1")));
    w.addDocument(doc);

    // 1
    doc = new Document();
    doc.add(new TextField(productIdField, "1", Field.Store.NO));
    doc.add(new TextField(typeField, "price", Field.Store.NO));
    doc.add(new TextField("price", "10.0", Field.Store.NO));
    doc.add(new SortedDocValuesField(joinField, new BytesRef("1")));
    w.addDocument(doc);

    // 2
    doc = new Document();
    doc.add(new TextField(productIdField, "1", Field.Store.NO));
    doc.add(new TextField(typeField, "price", Field.Store.NO));
    doc.add(new TextField("price", "20.0", Field.Store.NO));
    doc.add(new SortedDocValuesField(joinField, new BytesRef("1")));
    w.addDocument(doc);

    // 3
    doc = new Document();
    doc.add(new TextField(idField, "2", Field.Store.NO));
    doc.add(new TextField(typeField, "product", Field.Store.NO));
    doc.add(new TextField("description", "more random text", Field.Store.NO));
    doc.add(new TextField("name", "name2", Field.Store.NO));
    doc.add(new SortedDocValuesField(joinField, new BytesRef("2")));
    w.addDocument(doc);
    w.commit();

    // 4
    doc = new Document();
    doc.add(new TextField(productIdField, "2", Field.Store.NO));
    doc.add(new TextField(typeField, "price", Field.Store.NO));
    doc.add(new TextField("price", "10.0", Field.Store.NO));
    doc.add(new SortedDocValuesField(joinField, new BytesRef("2")));
    w.addDocument(doc);

    // 5
    doc = new Document();
    doc.add(new TextField(productIdField, "2", Field.Store.NO));
    doc.add(new TextField(typeField, "price", Field.Store.NO));
    doc.add(new TextField("price", "20.0", Field.Store.NO));
    doc.add(new SortedDocValuesField(joinField, new BytesRef("2")));
    w.addDocument(doc);

    IndexSearcher indexSearcher = new IndexSearcher(w.getReader());
    w.close();

    IndexReader r = indexSearcher.getIndexReader();
    SortedDocValues[] values = new SortedDocValues[r.leaves().size()];
    for (int i = 0; i < values.length; i++) {
      LeafReader leafReader = r.leaves().get(i).reader();
      values[i] = DocValues.getSorted(leafReader, joinField);
    }
    OrdinalMap ordinalMap = OrdinalMap.build(null, values, PackedInts.DEFAULT);

    Query toQuery = new TermQuery(new Term(typeField, "price"));
    Query fromQuery = new TermQuery(new Term("name", "name2"));
    // Search for product and return prices
    Query joinQuery =
        JoinUtil.createJoinQuery(
            joinField, fromQuery, toQuery, indexSearcher, ScoreMode.None, ordinalMap);
    TopDocs result = indexSearcher.search(joinQuery, 10);
    assertEquals(2, result.totalHits.value());
    assertEquals(4, result.scoreDocs[0].doc);
    assertEquals(5, result.scoreDocs[1].doc);

    fromQuery = new TermQuery(new Term("name", "name1"));
    joinQuery =
        JoinUtil.createJoinQuery(
            joinField, fromQuery, toQuery, indexSearcher, ScoreMode.None, ordinalMap);
    result = indexSearcher.search(joinQuery, 10);
    assertEquals(2, result.totalHits.value());
    assertEquals(1, result.scoreDocs[0].doc);
    assertEquals(2, result.scoreDocs[1].doc);

    // Search for prices and return products
    fromQuery = new TermQuery(new Term("price", "20.0"));
    toQuery = new TermQuery(new Term(typeField, "product"));
    joinQuery =
        JoinUtil.createJoinQuery(
            joinField, fromQuery, toQuery, indexSearcher, ScoreMode.None, ordinalMap);
    result = indexSearcher.search(joinQuery, 10);
    assertEquals(2, result.totalHits.value());
    assertEquals(0, result.scoreDocs[0].doc);
    assertEquals(3, result.scoreDocs[1].doc);

    indexSearcher.getIndexReader().close();
    dir.close();
  }

  public void testOrdinalsJoinExplainNoMatches() throws Exception {
    final String idField = "id";
    final String productIdField = "productId";
    // A field indicating to what type a document belongs, which is then used to distinguish between
    // documents during joining.
    final String typeField = "type";
    // A single sorted doc values field that holds the join values for all document types.
    // Typically during indexing a schema will automatically create this field with the values
    final String joinField = idField + productIdField;

    Directory dir = newDirectory();
    IndexWriter w =
        new IndexWriter(
            dir,
            newIndexWriterConfig(new MockAnalyzer(random()))
                .setMergePolicy(NoMergePolicy.INSTANCE));

    // 0
    Document doc = new Document();
    doc.add(new TextField(idField, "1", Field.Store.NO));
    doc.add(new TextField(typeField, "product", Field.Store.NO));
    doc.add(new TextField("description", "random text", Field.Store.NO));
    doc.add(new TextField("name", "name1", Field.Store.NO));
    doc.add(new SortedDocValuesField(joinField, new BytesRef("1")));
    w.addDocument(doc);

    // 1
    doc = new Document();
    doc.add(new TextField(idField, "2", Field.Store.NO));
    doc.add(new TextField(typeField, "product", Field.Store.NO));
    doc.add(new TextField("description", "random text", Field.Store.NO));
    doc.add(new TextField("name", "name2", Field.Store.NO));
    doc.add(new SortedDocValuesField(joinField, new BytesRef("2")));
    w.addDocument(doc);

    // 2
    doc = new Document();
    doc.add(new TextField(productIdField, "1", Field.Store.NO));
    doc.add(new TextField(typeField, "price", Field.Store.NO));
    doc.add(new TextField("price", "10.0", Field.Store.NO));
    doc.add(new SortedDocValuesField(joinField, new BytesRef("1")));
    w.addDocument(doc);

    // 3
    doc = new Document();
    doc.add(new TextField(productIdField, "2", Field.Store.NO));
    doc.add(new TextField(typeField, "price", Field.Store.NO));
    doc.add(new TextField("price", "20.0", Field.Store.NO));
    doc.add(new SortedDocValuesField(joinField, new BytesRef("1")));
    w.addDocument(doc);

    if (random().nextBoolean()) {
      w.flush();
    }

    // 4
    doc = new Document();
    doc.add(new TextField(productIdField, "3", Field.Store.NO));
    doc.add(new TextField(typeField, "price", Field.Store.NO));
    doc.add(new TextField("price", "5.0", Field.Store.NO));
    doc.add(new SortedDocValuesField(joinField, new BytesRef("2")));
    w.addDocument(doc);

    // 5
    doc = new Document();
    doc.add(new TextField("field", "value", Field.Store.NO));
    w.addDocument(doc);

    IndexReader r = DirectoryReader.open(w);
    IndexSearcher indexSearcher = new IndexSearcher(r);
    SortedDocValues[] values = new SortedDocValues[r.leaves().size()];
    for (int i = 0; i < values.length; i++) {
      LeafReader leafReader = r.leaves().get(i).reader();
      values[i] = DocValues.getSorted(leafReader, joinField);
    }
    OrdinalMap ordinalMap = OrdinalMap.build(null, values, PackedInts.DEFAULT);

    Query toQuery = new TermQuery(new Term("price", "5.0"));
    Query fromQuery = new TermQuery(new Term("name", "name2"));

    for (ScoreMode scoreMode : ScoreMode.values()) {
      Query joinQuery =
          JoinUtil.createJoinQuery(
              joinField, fromQuery, toQuery, indexSearcher, scoreMode, ordinalMap);
      TopDocs result = indexSearcher.search(joinQuery, 10);
      assertEquals(1, result.totalHits.value());
      assertEquals(4, result.scoreDocs[0].doc); // doc with price: 5.0
      Explanation explanation = indexSearcher.explain(joinQuery, 4);
      assertTrue(explanation.isMatch());
      assertEquals(explanation.getDescription(), "A match, join value 2");

      explanation = indexSearcher.explain(joinQuery, 3);
      assertFalse(explanation.isMatch());
      assertEquals(explanation.getDescription(), "Not a match, join value 1");

      explanation = indexSearcher.explain(joinQuery, 5);
      assertFalse(explanation.isMatch());
      assertEquals(explanation.getDescription(), "Not a match");
    }

    w.close();
    indexSearcher.getIndexReader().close();
    dir.close();
  }

  public void testRandomOrdinalsJoin() throws Exception {
    IndexIterationContext context = createContext(128, false, true);
    int searchIters = atLeast(1);
    IndexSearcher indexSearcher = context.searcher;
    for (int i = 0; i < searchIters; i++) {
      if (VERBOSE) {
        System.out.println("search iter=" + i);
      }
      int r = random().nextInt(context.randomUniqueValues.length);
      boolean from = context.randomFrom[r];
      String randomValue = context.randomUniqueValues[r];
      BitSet expectedResult =
          createExpectedResult(randomValue, from, indexSearcher.getIndexReader(), context);

      final Query actualQuery = new TermQuery(new Term("value", randomValue));
      if (VERBOSE) {
        System.out.println("actualQuery=" + actualQuery);
      }
      final ScoreMode scoreMode = ScoreMode.values()[random().nextInt(ScoreMode.values().length)];
      if (VERBOSE) {
        System.out.println("scoreMode=" + scoreMode);
      }

      final Query joinQuery;
      if (from) {
        BooleanQuery.Builder fromQuery = new BooleanQuery.Builder();
        fromQuery.add(new TermQuery(new Term("type", "from")), BooleanClause.Occur.FILTER);
        fromQuery.add(actualQuery, BooleanClause.Occur.MUST);
        Query toQuery = new TermQuery(new Term("type", "to"));
        joinQuery =
            JoinUtil.createJoinQuery(
                "join_field",
                fromQuery.build(),
                toQuery,
                indexSearcher,
                scoreMode,
                context.ordinalMap);
      } else {
        BooleanQuery.Builder fromQuery = new BooleanQuery.Builder();
        fromQuery.add(new TermQuery(new Term("type", "to")), BooleanClause.Occur.FILTER);
        fromQuery.add(actualQuery, BooleanClause.Occur.MUST);
        Query toQuery = new TermQuery(new Term("type", "from"));
        joinQuery =
            JoinUtil.createJoinQuery(
                "join_field",
                fromQuery.build(),
                toQuery,
                indexSearcher,
                scoreMode,
                context.ordinalMap);
      }
      if (VERBOSE) {
        System.out.println("joinQuery=" + joinQuery);
      }

      Object[] searchResults =
          indexSearcher.search(
              joinQuery,
              new MultiCollectorManager(
                  new BitSetCollectorManager(indexSearcher.getIndexReader().maxDoc()),
                  new TopScoreDocCollectorManager(10, null, Integer.MAX_VALUE)));
      final BitSet actualResult = (BitSet) searchResults[0];
      assertBitSet(expectedResult, actualResult, indexSearcher);
      TopDocs expectedTopDocs = createExpectedTopDocs(randomValue, from, scoreMode, context);
      TopDocs actualTopDocs = (TopDocs) searchResults[1];
      assertTopDocs(expectedTopDocs, actualTopDocs, scoreMode, indexSearcher, joinQuery);
    }
    context.close();
  }

  public void testMinMaxScore() throws Exception {
    String priceField = "price";
    Query priceQuery = numericDocValuesScoreQuery(priceField);

    Directory dir = newDirectory();
    RandomIndexWriter iw =
        new RandomIndexWriter(
            random(),
            dir,
            newIndexWriterConfig(new MockAnalyzer(random(), MockTokenizer.KEYWORD, false))
                .setMergePolicy(newMergePolicy(random(), false)));

    Map<String, Float> lowestScoresPerParent = new HashMap<>();
    Map<String, Float> highestScoresPerParent = new HashMap<>();
    int numParents = RandomNumbers.randomIntBetween(random(), 16, 64);
    for (int p = 0; p < numParents; p++) {
      String parentId = Integer.toString(p);
      Document parentDoc = new Document();
      parentDoc.add(new StringField("id", parentId, Field.Store.YES));
      parentDoc.add(new StringField("type", "to", Field.Store.NO));
      parentDoc.add(new SortedDocValuesField("join_field", new BytesRef(parentId)));
      iw.addDocument(parentDoc);
      int numChildren = RandomNumbers.randomIntBetween(random(), 2, 16);
      int lowest = Integer.MAX_VALUE;
      int highest = Integer.MIN_VALUE;
      for (int c = 0; c < numChildren; c++) {
        String childId = Integer.toString(p + c);
        Document childDoc = new Document();
        childDoc.add(new StringField("id", childId, Field.Store.YES));
        childDoc.add(new StringField("type", "from", Field.Store.NO));
        childDoc.add(new SortedDocValuesField("join_field", new BytesRef(parentId)));
        int price = random().nextInt(1000);
        childDoc.add(new NumericDocValuesField(priceField, price));
        iw.addDocument(childDoc);
        lowest = Math.min(lowest, price);
        highest = Math.max(highest, price);
      }
      lowestScoresPerParent.put(parentId, (float) lowest);
      highestScoresPerParent.put(parentId, (float) highest);
    }
    iw.close();

    IndexSearcher searcher = new IndexSearcher(DirectoryReader.open(dir));
    SortedDocValues[] values = new SortedDocValues[searcher.getIndexReader().leaves().size()];
    for (LeafReaderContext leadContext : searcher.getIndexReader().leaves()) {
      values[leadContext.ord] = DocValues.getSorted(leadContext.reader(), "join_field");
    }
    OrdinalMap ordinalMap = OrdinalMap.build(null, values, PackedInts.DEFAULT);
    BooleanQuery.Builder fromQuery = new BooleanQuery.Builder();
    fromQuery.add(priceQuery, BooleanClause.Occur.MUST);
    Query toQuery = new TermQuery(new Term("type", "to"));
    Query joinQuery =
        JoinUtil.createJoinQuery(
            "join_field", fromQuery.build(), toQuery, searcher, ScoreMode.Min, ordinalMap);
    TopDocs topDocs = searcher.search(joinQuery, numParents);
    assertEquals(numParents, topDocs.totalHits.value());
    for (int i = 0; i < topDocs.scoreDocs.length; i++) {
      ScoreDoc scoreDoc = topDocs.scoreDocs[i];
      String id = searcher.storedFields().document(scoreDoc.doc).get("id");
      assertEquals(lowestScoresPerParent.get(id), scoreDoc.score, 0f);
    }
    checkBoost(joinQuery, searcher);

    joinQuery =
        JoinUtil.createJoinQuery(
            "join_field", fromQuery.build(), toQuery, searcher, ScoreMode.Max, ordinalMap);
    topDocs = searcher.search(joinQuery, numParents);
    assertEquals(numParents, topDocs.totalHits.value());
    for (int i = 0; i < topDocs.scoreDocs.length; i++) {
      ScoreDoc scoreDoc = topDocs.scoreDocs[i];
      String id = searcher.storedFields().document(scoreDoc.doc).get("id");
      assertEquals(highestScoresPerParent.get(id), scoreDoc.score, 0f);
    }
    checkBoost(joinQuery, searcher);

    searcher.getIndexReader().close();
    dir.close();
  }

  // FunctionQuery would be helpful, but join module doesn't depend on queries module.
  static Query numericDocValuesScoreQuery(final String field) {
    return new Query() {

      private final Query fieldQuery = new FieldExistsQuery(field);

      @Override
      public Weight createWeight(
          IndexSearcher searcher, org.apache.lucene.search.ScoreMode scoreMode, float boost)
          throws IOException {
        Weight fieldWeight =
            fieldQuery.createWeight(
                searcher, org.apache.lucene.search.ScoreMode.COMPLETE_NO_SCORES, boost);
        return new Weight(this) {

          @Override
          public Explanation explain(LeafReaderContext context, int doc) throws IOException {
            return null;
          }

          @Override
          public ScorerSupplier scorerSupplier(LeafReaderContext context) throws IOException {
            Scorer fieldScorer = fieldWeight.scorer(context);
            if (fieldScorer == null) {
              return null;
            }
            NumericDocValues price = context.reader().getNumericDocValues(field);
            final var scorer =
                new FilterScorer(fieldScorer) {
                  @Override
                  public float score() throws IOException {
                    assertEquals(in.docID(), price.advance(in.docID()));
                    return (float) price.longValue();
                  }

                  @Override
                  public float getMaxScore(int upTo) throws IOException {
                    return Float.POSITIVE_INFINITY;
                  }
                };
            return new DefaultScorerSupplier(scorer);
          }

          @Override
          public boolean isCacheable(LeafReaderContext ctx) {
            return false;
          }
        };
      }

      @Override
      public void visit(QueryVisitor visitor) {}

      @Override
      public String toString(String field) {
        return fieldQuery.toString(field);
      }

      @Override
      public boolean equals(Object o) {
        return o == this;
      }

      @Override
      public int hashCode() {
        return System.identityHashCode(this);
      }
    };
  }

  public void testMinMaxDocs() throws Exception {
    Directory dir = newDirectory();
    RandomIndexWriter iw =
        new RandomIndexWriter(
            random(),
            dir,
            newIndexWriterConfig(new MockAnalyzer(random(), MockTokenizer.KEYWORD, false))
                .setMergePolicy(newMergePolicy(random(), false)));

    int minChildDocsPerParent = 2;
    int maxChildDocsPerParent = 16;
    int numParents = RandomNumbers.randomIntBetween(random(), 16, 64);
    int[] childDocsPerParent = new int[numParents];
    for (int p = 0; p < numParents; p++) {
      String parentId = Integer.toString(p);
      Document parentDoc = new Document();
      parentDoc.add(new StringField("id", parentId, Field.Store.YES));
      parentDoc.add(new StringField("type", "to", Field.Store.NO));
      parentDoc.add(new SortedDocValuesField("join_field", new BytesRef(parentId)));
      iw.addDocument(parentDoc);
      int numChildren =
          RandomNumbers.randomIntBetween(random(), minChildDocsPerParent, maxChildDocsPerParent);
      childDocsPerParent[p] = numChildren;
      for (int c = 0; c < numChildren; c++) {
        String childId = Integer.toString(p + c);
        Document childDoc = new Document();
        childDoc.add(new StringField("id", childId, Field.Store.YES));
        childDoc.add(new StringField("type", "from", Field.Store.NO));
        childDoc.add(new SortedDocValuesField("join_field", new BytesRef(parentId)));
        iw.addDocument(childDoc);
      }
    }
    iw.close();

    IndexSearcher searcher = new IndexSearcher(DirectoryReader.open(dir));
    SortedDocValues[] values = new SortedDocValues[searcher.getIndexReader().leaves().size()];
    for (LeafReaderContext leadContext : searcher.getIndexReader().leaves()) {
      values[leadContext.ord] = DocValues.getSorted(leadContext.reader(), "join_field");
    }
    OrdinalMap ordinalMap = OrdinalMap.build(null, values, PackedInts.DEFAULT);
    Query fromQuery = new TermQuery(new Term("type", "from"));
    Query toQuery = new TermQuery(new Term("type", "to"));

    int iters = RandomNumbers.randomIntBetween(random(), 3, 9);
    for (int i = 1; i <= iters; i++) {
      final ScoreMode scoreMode = ScoreMode.values()[random().nextInt(ScoreMode.values().length)];
      int min =
          RandomNumbers.randomIntBetween(
              random(), minChildDocsPerParent, maxChildDocsPerParent - 1);
      int max = RandomNumbers.randomIntBetween(random(), min, maxChildDocsPerParent);
      if (VERBOSE) {
        System.out.println("iter=" + i);
        System.out.println("scoreMode=" + scoreMode);
        System.out.println("min=" + min);
        System.out.println("max=" + max);
      }
      Query joinQuery =
          JoinUtil.createJoinQuery(
              "join_field", fromQuery, toQuery, searcher, scoreMode, ordinalMap, min, max);
      int totalHits = searcher.count(joinQuery);
      int expectedCount = 0;
      for (int numChildDocs : childDocsPerParent) {
        if (numChildDocs >= min && numChildDocs <= max) {
          expectedCount++;
        }
      }
      assertEquals(expectedCount, totalHits);
      checkBoost(joinQuery, searcher);
    }
    searcher.getIndexReader().close();
    dir.close();
  }

  public void testRewrite() throws IOException {
    Directory dir = newDirectory();
    RandomIndexWriter w =
        new RandomIndexWriter(
            random(), dir, newIndexWriterConfig().setMergePolicy(newMergePolicy(random(), false)));
    Document doc = new Document();
    doc.add(new SortedDocValuesField("join_field", new BytesRef("abc")));
    w.addDocument(doc);
    doc = new Document();
    doc.add(new SortedDocValuesField("join_field", new BytesRef("abd")));
    w.addDocument(doc);
    IndexReader reader = w.getReader();
    IndexSearcher searcher = newSearcher(reader);
    OrdinalMap ordMap = OrdinalMap.build(null, new SortedDocValues[0], 0f);
    {
      Query joinQuery =
          JoinUtil.createJoinQuery(
              "join_field",
              MatchNoDocsQuery.INSTANCE,
              MatchNoDocsQuery.INSTANCE,
              searcher,
              RandomPicks.randomFrom(random(), ScoreMode.values()),
              ordMap,
              0,
              Integer.MAX_VALUE);
      searcher.search(joinQuery, 1); // no exception due to missing rewrites
    }
    {
      Query joinQuery =
          JoinUtil.createJoinQuery(
              "join_field",
              MatchNoDocsQuery.INSTANCE,
              MatchNoDocsQuery.INSTANCE,
              searcher,
              ScoreMode.None,
              ordMap,
              1,
              Integer.MAX_VALUE);
      Query rewritten = searcher.rewrite(joinQuery);
      // should simplify to GlobalOrdinalsQuery since min is set to 1
      assertTrue(rewritten instanceof GlobalOrdinalsQuery);
      searcher.search(joinQuery, 1); // no exception due to missing rewrites
    }
    reader.close();
    w.close();
    dir.close();
  }

  // TermsWithScoreCollector.MV.Avg forgets to grow beyond
  // TermsWithScoreCollector.INITIAL_ARRAY_SIZE
  public void testOverflowTermsWithScoreCollector() throws Exception {
    test300spartans(true, ScoreMode.Avg);
  }

  public void testOverflowTermsWithScoreCollectorRandom() throws Exception {
    test300spartans(
        random().nextBoolean(), ScoreMode.values()[random().nextInt(ScoreMode.values().length)]);
  }

  void test300spartans(boolean multipleValues, ScoreMode scoreMode) throws Exception {
    final String idField = "id";
    final String toField = "productId";

    Directory dir = newDirectory();
    RandomIndexWriter w =
        new RandomIndexWriter(
            random(),
            dir,
            newIndexWriterConfig(new MockAnalyzer(random())).setMergePolicy(newLogMergePolicy()));

    // 0
    Document doc = new Document();
    doc.add(new TextField("description", "random text", Field.Store.NO));
    doc.add(new TextField("name", "name1", Field.Store.NO));
    doc.add(new TextField(idField, "0", Field.Store.NO));
    doc.add(new SortedDocValuesField(idField, new BytesRef("0")));
    w.addDocument(doc);

    doc = new Document();
    doc.add(new TextField("price", "10.0", Field.Store.NO));

    if (multipleValues) {
      for (int i = 0; i < 300; i++) {
        doc.add(new SortedSetDocValuesField(toField, new BytesRef("" + i)));
      }
    } else {
      doc.add(new SortedDocValuesField(toField, new BytesRef("0")));
    }
    w.addDocument(doc);

    IndexSearcher indexSearcher = new IndexSearcher(w.getReader());
    w.close();

    // Search for product
    Query joinQuery =
        JoinUtil.createJoinQuery(
            toField,
            multipleValues,
            idField,
            new TermQuery(new Term("price", "10.0")),
            indexSearcher,
            scoreMode);

    TopDocs result = indexSearcher.search(joinQuery, 10);
    assertEquals(1, result.totalHits.value());
    assertEquals(0, result.scoreDocs[0].doc);

    indexSearcher.getIndexReader().close();
    dir.close();
  }

  /**
   * LUCENE-5487: verify a join query inside a SHOULD BQ will still use the join query's optimized
   * BulkScorers
   */
  public void testInsideBooleanQuery() throws Exception {
    final String idField = "id";
    final String toField = "productId";

    Directory dir = newDirectory();
    RandomIndexWriter w =
        new RandomIndexWriter(
            random(),
            dir,
            newIndexWriterConfig(new MockAnalyzer(random())).setMergePolicy(newLogMergePolicy()));

    // 0
    Document doc = new Document();
    doc.add(new TextField("description", "random text", Field.Store.NO));
    doc.add(new TextField("name", "name1", Field.Store.NO));
    doc.add(new TextField(idField, "7", Field.Store.NO));
    doc.add(new SortedDocValuesField(idField, new BytesRef("7")));
    w.addDocument(doc);

    // 1
    doc = new Document();
    doc.add(new TextField("price", "10.0", Field.Store.NO));
    doc.add(new TextField(idField, "2", Field.Store.NO));
    doc.add(new SortedDocValuesField(idField, new BytesRef("2")));
    doc.add(new TextField(toField, "7", Field.Store.NO));
    w.addDocument(doc);

    // 2
    doc = new Document();
    doc.add(new TextField("price", "20.0", Field.Store.NO));
    doc.add(new TextField(idField, "3", Field.Store.NO));
    doc.add(new SortedDocValuesField(idField, new BytesRef("3")));
    doc.add(new TextField(toField, "7", Field.Store.NO));
    w.addDocument(doc);

    // 3
    doc = new Document();
    doc.add(new TextField("description", "more random text", Field.Store.NO));
    doc.add(new TextField("name", "name2", Field.Store.NO));
    doc.add(new TextField(idField, "0", Field.Store.NO));
    doc.add(new SortedDocValuesField(idField, new BytesRef("0")));
    w.addDocument(doc);
    w.commit();

    // 4
    doc = new Document();
    doc.add(new TextField("price", "10.0", Field.Store.NO));
    doc.add(new TextField(idField, "5", Field.Store.NO));
    doc.add(new SortedDocValuesField(idField, new BytesRef("5")));
    doc.add(new TextField(toField, "0", Field.Store.NO));
    w.addDocument(doc);

    // 5
    doc = new Document();
    doc.add(new TextField("price", "20.0", Field.Store.NO));
    doc.add(new TextField(idField, "6", Field.Store.NO));
    doc.add(new SortedDocValuesField(idField, new BytesRef("6")));
    doc.add(new TextField(toField, "0", Field.Store.NO));
    w.addDocument(doc);

    w.forceMerge(1);
    // single segment test, that relies on sequential collection, the searcher is also created
    // without an executor
    IndexSearcher indexSearcher = new IndexSearcher(w.getReader());
    w.close();

    // Search for product
    Query joinQuery =
        JoinUtil.createJoinQuery(
            idField,
            false,
            toField,
            new TermQuery(new Term("description", "random")),
            indexSearcher,
            ScoreMode.Avg);

    BooleanQuery.Builder bq = new BooleanQuery.Builder();
    bq.add(joinQuery, BooleanClause.Occur.SHOULD);
    bq.add(new TermQuery(new Term("id", "3")), BooleanClause.Occur.SHOULD);

    indexSearcher.search(
        bq.build(),
        new CollectorManager<SimpleCollector, Void>() {
          @Override
          public SimpleCollector newCollector() {
            return new SimpleCollector() {
              boolean sawFive;

              @Override
              public void collect(int docID) {
                // Hairy / evil (depends on how BooleanScorer
                // stores temporarily collected docIDs by
                // appending to head of linked list):
                if (docID == 5) {
                  sawFive = true;
                } else if (docID == 1) {
                  assertFalse(
                      "optimized bulkScorer was not used for join query embedded in boolean query!",
                      sawFive);
                }
              }

              @Override
              public org.apache.lucene.search.ScoreMode scoreMode() {
                return org.apache.lucene.search.ScoreMode.COMPLETE_NO_SCORES;
              }
            };
          }

          @Override
          public Void reduce(Collection<SimpleCollector> collectors) {
            assert collectors.size() == 1 : "concurrent execution is not supported by this test";
            return null;
          }
        });

    indexSearcher.getIndexReader().close();
    dir.close();
  }

  public void testSimpleWithScoring() throws Exception {
    final String idField = "id";
    final String toField = "movieId";

    Directory dir = newDirectory();
    RandomIndexWriter w =
        new RandomIndexWriter(
            random(),
            dir,
            newIndexWriterConfig(new MockAnalyzer(random())).setMergePolicy(newLogMergePolicy()));

    // 0
    Document doc = new Document();
    doc.add(new TextField("description", "A random movie", Field.Store.NO));
    doc.add(new TextField("name", "Movie 1", Field.Store.NO));
    doc.add(new TextField(idField, "1", Field.Store.NO));
    doc.add(new SortedDocValuesField(idField, new BytesRef("1")));
    w.addDocument(doc);

    // 1
    doc = new Document();
    doc.add(new TextField("subtitle", "The first subtitle of this movie", Field.Store.NO));
    doc.add(new TextField(idField, "2", Field.Store.NO));
    doc.add(new SortedDocValuesField(idField, new BytesRef("2")));
    doc.add(new TextField(toField, "1", Field.Store.NO));
    doc.add(new SortedDocValuesField(toField, new BytesRef("1")));
    w.addDocument(doc);

    // 2
    doc = new Document();
    doc.add(new TextField("subtitle", "random subtitle; random event movie", Field.Store.NO));
    doc.add(new TextField(idField, "3", Field.Store.NO));
    doc.add(new SortedDocValuesField(idField, new BytesRef("3")));
    doc.add(new TextField(toField, "1", Field.Store.NO));
    doc.add(new SortedDocValuesField(toField, new BytesRef("1")));
    w.addDocument(doc);

    // 3
    doc = new Document();
    doc.add(new TextField("description", "A second random movie", Field.Store.NO));
    doc.add(new TextField("name", "Movie 2", Field.Store.NO));
    doc.add(new TextField(idField, "4", Field.Store.NO));
    doc.add(new SortedDocValuesField(idField, new BytesRef("4")));
    w.addDocument(doc);
    w.commit();

    // 4
    doc = new Document();
    doc.add(
        new TextField(
            "subtitle", "a very random event happened during christmas night", Field.Store.NO));
    doc.add(new TextField(idField, "5", Field.Store.NO));
    doc.add(new SortedDocValuesField(idField, new BytesRef("5")));
    doc.add(new TextField(toField, "4", Field.Store.NO));
    doc.add(new SortedDocValuesField(toField, new BytesRef("4")));
    w.addDocument(doc);

    // 5
    doc = new Document();
    doc.add(new TextField("subtitle", "movie end movie test 123 test 123 random", Field.Store.NO));
    doc.add(new TextField(idField, "6", Field.Store.NO));
    doc.add(new SortedDocValuesField(idField, new BytesRef("6")));
    doc.add(new TextField(toField, "4", Field.Store.NO));
    doc.add(new SortedDocValuesField(toField, new BytesRef("4")));
    w.addDocument(doc);

    IndexSearcher indexSearcher = new IndexSearcher(w.getReader());
    w.close();

    // Search for movie via subtitle
    Query joinQuery =
        JoinUtil.createJoinQuery(
            toField,
            false,
            idField,
            new TermQuery(new Term("subtitle", "random")),
            indexSearcher,
            ScoreMode.Max);
    TopDocs result = indexSearcher.search(joinQuery, 10);
    assertEquals(2, result.totalHits.value());
    assertEquals(0, result.scoreDocs[0].doc);
    assertEquals(3, result.scoreDocs[1].doc);
    checkBoost(joinQuery, indexSearcher);

    // Score mode max.
    joinQuery =
        JoinUtil.createJoinQuery(
            toField,
            false,
            idField,
            new TermQuery(new Term("subtitle", "movie")),
            indexSearcher,
            ScoreMode.Max);
    result = indexSearcher.search(joinQuery, 10);
    assertEquals(2, result.totalHits.value());
    assertEquals(3, result.scoreDocs[0].doc);
    assertEquals(0, result.scoreDocs[1].doc);
    checkBoost(joinQuery, indexSearcher);

    // Score mode total
    joinQuery =
        JoinUtil.createJoinQuery(
            toField,
            false,
            idField,
            new TermQuery(new Term("subtitle", "movie")),
            indexSearcher,
            ScoreMode.Total);
    result = indexSearcher.search(joinQuery, 10);
    assertEquals(2, result.totalHits.value());
    assertEquals(0, result.scoreDocs[0].doc);
    assertEquals(3, result.scoreDocs[1].doc);
    checkBoost(joinQuery, indexSearcher);

    // Score mode avg
    joinQuery =
        JoinUtil.createJoinQuery(
            toField,
            false,
            idField,
            new TermQuery(new Term("subtitle", "movie")),
            indexSearcher,
            ScoreMode.Avg);
    result = indexSearcher.search(joinQuery, 10);
    assertEquals(2, result.totalHits.value());
    assertEquals(3, result.scoreDocs[0].doc);
    assertEquals(0, result.scoreDocs[1].doc);
    checkBoost(joinQuery, indexSearcher);

    indexSearcher.getIndexReader().close();
    dir.close();
  }

  private void checkBoost(Query query, IndexSearcher searcher) throws IOException {
    TopDocs result = searcher.search(query, 10);
    if (result.scoreDocs.length == 0) {
      return;
    }
    Query boostedQuery = new BoostQuery(query, 10);
    TopDocs boostedResult = searcher.search(boostedQuery, 10);
    assertEquals(result.scoreDocs[0].score * 10, boostedResult.scoreDocs[0].score, 0.000001f);
    QueryUtils.checkExplanations(boostedQuery, searcher);
  }

  public void testEquals() throws Exception {
    final int numDocs = atLeast(random(), 50);
    try (final Directory dir = newDirectory()) {
      try (final RandomIndexWriter w =
          new RandomIndexWriter(
              random(),
              dir,
              newIndexWriterConfig(new MockAnalyzer(random()))
                  .setMergePolicy(newLogMergePolicy()))) {
        boolean multiValued = random().nextBoolean();
        String joinField = multiValued ? "mvField" : "svField";
        for (int id = 0; id < numDocs; id++) {
          Document doc = new Document();
          doc.add(new TextField("id", "" + id, Field.Store.NO));
          doc.add(new TextField("name", "name" + (id % 7), Field.Store.NO));
          if (multiValued) {
            int numValues = 1 + random().nextInt(2);
            for (int i = 0; i < numValues; i++) {
              doc.add(
                  new SortedSetDocValuesField(joinField, new BytesRef("" + random().nextInt(13))));
            }
          } else {
            doc.add(new SortedDocValuesField(joinField, new BytesRef("" + random().nextInt(13))));
          }
          w.addDocument(doc);
        }

        Set<ScoreMode> scoreModes = EnumSet.allOf(ScoreMode.class);
        ScoreMode scoreMode1 = RandomPicks.randomFrom(random(), scoreModes);
        scoreModes.remove(scoreMode1);
        ScoreMode scoreMode2 = RandomPicks.randomFrom(random(), scoreModes);

        final Query x;
        try (IndexReader r = w.getReader()) {
          IndexSearcher indexSearcher = new IndexSearcher(r);
          x =
              JoinUtil.createJoinQuery(
                  joinField,
                  multiValued,
                  joinField,
                  new TermQuery(new Term("name", "name5")),
                  indexSearcher,
                  scoreMode1);
          assertEquals(
              "identical calls to createJoinQuery",
              x,
              JoinUtil.createJoinQuery(
                  joinField,
                  multiValued,
                  joinField,
                  new TermQuery(new Term("name", "name5")),
                  indexSearcher,
                  scoreMode1));

          assertFalse(
              "score mode (" + scoreMode1 + " != " + scoreMode2 + "), but queries are equal",
              x.equals(
                  JoinUtil.createJoinQuery(
                      joinField,
                      multiValued,
                      joinField,
                      new TermQuery(new Term("name", "name5")),
                      indexSearcher,
                      scoreMode2)));

          assertFalse(
              "from fields (joinField != \"other_field\") but queries equals",
              x.equals(
                  JoinUtil.createJoinQuery(
                      joinField,
                      multiValued,
                      "other_field",
                      new TermQuery(new Term("name", "name5")),
                      indexSearcher,
                      scoreMode1)));

          assertFalse(
              "from fields (\"other_field\" != joinField) but queries equals",
              x.equals(
                  JoinUtil.createJoinQuery(
                      "other_field",
                      multiValued,
                      joinField,
                      new TermQuery(new Term("name", "name5")),
                      indexSearcher,
                      scoreMode1)));

          assertFalse(
              "fromQuery (name:name5 != name:name6) but queries equals",
              x.equals(
                  JoinUtil.createJoinQuery(
                      "other_field",
                      multiValued,
                      joinField,
                      new TermQuery(new Term("name", "name6")),
                      indexSearcher,
                      scoreMode1)));
        }

        for (int i = 0; i < 13; i++) {
          Document doc = new Document();
          doc.add(new TextField("id", "new_id", Field.Store.NO));
          doc.add(new TextField("name", "name5", Field.Store.NO));
          if (multiValued) {
            int numValues = 1 + random().nextInt(2);
            for (int j = 0; j < numValues; j++) {
              doc.add(new SortedSetDocValuesField(joinField, new BytesRef("" + i)));
            }
          } else {
            doc.add(new SortedDocValuesField(joinField, new BytesRef("" + i)));
          }
          w.addDocument(doc);
        }
        try (IndexReader r = w.getReader()) {
          IndexSearcher indexSearcher = new IndexSearcher(r);
          assertFalse(
              "Query shouldn't be equal, because different index readers ",
              x.equals(
                  JoinUtil.createJoinQuery(
                      joinField,
                      multiValued,
                      joinField,
                      new TermQuery(new Term("name", "name5")),
                      indexSearcher,
                      scoreMode1)));
        }
      }
    }
  }

  public void testEquals_globalOrdinalsJoin() throws Exception {
    final int numDocs = atLeast(random(), 50);
    try (final Directory dir = newDirectory()) {
      try (final RandomIndexWriter w =
          new RandomIndexWriter(
              random(),
              dir,
              newIndexWriterConfig(new MockAnalyzer(random()))
                  .setMergePolicy(newLogMergePolicy()))) {
        String joinField = "field";
        for (int id = 0; id < numDocs; id++) {
          Document doc = new Document();
          doc.add(new TextField("id", "" + id, Field.Store.NO));
          doc.add(new TextField("name", "name" + (id % 7), Field.Store.NO));
          doc.add(new SortedDocValuesField(joinField, new BytesRef("" + random().nextInt(13))));
          w.addDocument(doc);
        }

        Set<ScoreMode> scoreModes = EnumSet.allOf(ScoreMode.class);
        ScoreMode scoreMode1 = RandomPicks.randomFrom(random(), scoreModes);
        scoreModes.remove(scoreMode1);
        ScoreMode scoreMode2 = RandomPicks.randomFrom(random(), scoreModes);

        final Query x;
        try (IndexReader r = w.getReader()) {
          SortedDocValues[] values = new SortedDocValues[r.leaves().size()];
          for (int i = 0; i < values.length; i++) {
            LeafReader leafReader = r.leaves().get(i).reader();
            values[i] = DocValues.getSorted(leafReader, joinField);
          }
          OrdinalMap ordinalMap = OrdinalMap.build(null, values, PackedInts.DEFAULT);
          IndexSearcher indexSearcher = new IndexSearcher(r);
          x =
              JoinUtil.createJoinQuery(
                  joinField,
                  new TermQuery(new Term("name", "name5")),
                  MatchAllDocsQuery.INSTANCE,
                  indexSearcher,
                  scoreMode1,
                  ordinalMap);
          assertEquals(
              "identical calls to createJoinQuery",
              x,
              JoinUtil.createJoinQuery(
                  joinField,
                  new TermQuery(new Term("name", "name5")),
                  MatchAllDocsQuery.INSTANCE,
                  indexSearcher,
                  scoreMode1,
                  ordinalMap));

          assertFalse(
              "score mode (" + scoreMode1 + " != " + scoreMode2 + "), but queries are equal",
              x.equals(
                  JoinUtil.createJoinQuery(
                      joinField,
                      new TermQuery(new Term("name", "name5")),
                      MatchAllDocsQuery.INSTANCE,
                      indexSearcher,
                      scoreMode2,
                      ordinalMap)));
          assertFalse(
              "fromQuery (name:name5 != name:name6) but queries equals",
              x.equals(
                  JoinUtil.createJoinQuery(
                      joinField,
                      new TermQuery(new Term("name", "name6")),
                      MatchAllDocsQuery.INSTANCE,
                      indexSearcher,
                      scoreMode1,
                      ordinalMap)));
        }

        for (int i = 0; i < 13; i++) {
          Document doc = new Document();
          doc.add(new TextField("id", "new_id", Field.Store.NO));
          doc.add(new TextField("name", "name5", Field.Store.NO));
          doc.add(new SortedDocValuesField(joinField, new BytesRef("" + i)));
          w.addDocument(doc);
        }
        try (IndexReader r = w.getReader()) {
          SortedDocValues[] values = new SortedDocValues[r.leaves().size()];
          for (int i = 0; i < values.length; i++) {
            LeafReader leafReader = r.leaves().get(i).reader();
            values[i] = DocValues.getSorted(leafReader, joinField);
          }
          OrdinalMap ordinalMap = OrdinalMap.build(null, values, PackedInts.DEFAULT);
          IndexSearcher indexSearcher = new IndexSearcher(r);
          assertFalse(
              "Query shouldn't be equal, because different index readers ",
              x.equals(
                  JoinUtil.createJoinQuery(
                      joinField,
                      new TermQuery(new Term("name", "name5")),
                      MatchAllDocsQuery.INSTANCE,
                      indexSearcher,
                      scoreMode1,
                      ordinalMap)));
        }
      }
    }
  }

  public void testEquals_numericJoin() throws Exception {
    final int numDocs = atLeast(random(), 50);
    try (final Directory dir = newDirectory()) {
      try (final RandomIndexWriter w =
          new RandomIndexWriter(
              random(),
              dir,
              newIndexWriterConfig(new MockAnalyzer(random()))
                  .setMergePolicy(newLogMergePolicy()))) {
        boolean multiValued = random().nextBoolean();
        String joinField = multiValued ? "mvField" : "svField";
        for (int id = 0; id < numDocs; id++) {
          Document doc = new Document();
          doc.add(new TextField("id", "" + id, Field.Store.NO));
          doc.add(new TextField("name", "name" + (id % 7), Field.Store.NO));
          if (multiValued) {
            int numValues = 1 + random().nextInt(2);
            for (int i = 0; i < numValues; i++) {
              doc.add(new IntPoint(joinField, random().nextInt(13)));
              doc.add(new SortedNumericDocValuesField(joinField, random().nextInt(13)));
            }
          } else {
            doc.add(new IntPoint(joinField, random().nextInt(13)));
            doc.add(new NumericDocValuesField(joinField, random().nextInt(13)));
          }
          w.addDocument(doc);
        }

        Set<ScoreMode> scoreModes = EnumSet.allOf(ScoreMode.class);
        ScoreMode scoreMode1 =
            scoreModes.toArray(ScoreMode[]::new)[random().nextInt(scoreModes.size())];
        scoreModes.remove(scoreMode1);
        ScoreMode scoreMode2 =
            scoreModes.toArray(ScoreMode[]::new)[random().nextInt(scoreModes.size())];

        final Query x;
        try (IndexReader r = w.getReader()) {
          IndexSearcher indexSearcher = new IndexSearcher(r);
          x =
              JoinUtil.createJoinQuery(
                  joinField,
                  multiValued,
                  joinField,
                  Integer.class,
                  new TermQuery(new Term("name", "name5")),
                  indexSearcher,
                  scoreMode1);
          assertEquals(
              "identical calls to createJoinQuery",
              x,
              JoinUtil.createJoinQuery(
                  joinField,
                  multiValued,
                  joinField,
                  Integer.class,
                  new TermQuery(new Term("name", "name5")),
                  indexSearcher,
                  scoreMode1));

          assertFalse(
              "score mode (" + scoreMode1 + " != " + scoreMode2 + "), but queries are equal",
              x.equals(
                  JoinUtil.createJoinQuery(
                      joinField,
                      multiValued,
                      joinField,
                      Integer.class,
                      new TermQuery(new Term("name", "name5")),
                      indexSearcher,
                      scoreMode2)));

          assertFalse(
              "from fields (joinField != \"other_field\") but queries equals",
              x.equals(
                  JoinUtil.createJoinQuery(
                      joinField,
                      multiValued,
                      "other_field",
                      Integer.class,
                      new TermQuery(new Term("name", "name5")),
                      indexSearcher,
                      scoreMode1)));

          assertFalse(
              "from fields (\"other_field\" != joinField) but queries equals",
              x.equals(
                  JoinUtil.createJoinQuery(
                      "other_field",
                      multiValued,
                      joinField,
                      Integer.class,
                      new TermQuery(new Term("name", "name5")),
                      indexSearcher,
                      scoreMode1)));

          assertFalse(
              "fromQuery (name:name5 != name:name6) but queries equals",
              x.equals(
                  JoinUtil.createJoinQuery(
                      "other_field",
                      multiValued,
                      joinField,
                      Integer.class,
                      new TermQuery(new Term("name", "name6")),
                      indexSearcher,
                      scoreMode1)));
        }

        for (int i = 14; i < 26; i++) {
          Document doc = new Document();
          doc.add(new TextField("id", "new_id", Field.Store.NO));
          doc.add(new TextField("name", "name5", Field.Store.NO));
          if (multiValued) {
            int numValues = 1 + random().nextInt(2);
            for (int j = 0; j < numValues; j++) {
              doc.add(new SortedNumericDocValuesField(joinField, i));
              doc.add(new IntPoint(joinField, i));
            }
          } else {
            doc.add(new NumericDocValuesField(joinField, i));
            doc.add(new IntPoint(joinField, i));
          }
          w.addDocument(doc);
        }
        try (IndexReader r = w.getReader()) {
          IndexSearcher indexSearcher = new IndexSearcher(r);
          assertFalse(
              "Query shouldn't be equal, because new join values have been indexed",
              x.equals(
                  JoinUtil.createJoinQuery(
                      joinField,
                      multiValued,
                      joinField,
                      Integer.class,
                      new TermQuery(new Term("name", "name5")),
                      indexSearcher,
                      scoreMode1)));
        }
      }
    }
  }

  @Test
  public void testSingleValueRandomJoin() throws Exception {
    int maxIndexIter = atLeast(1);
    int maxSearchIter = atLeast(1);
    executeRandomJoin(false, maxIndexIter, maxSearchIter, TestUtil.nextInt(random(), 87, 764));
  }

  @Test
  // This test really takes more time, that is why the number of iterations are smaller.
  public void testMultiValueRandomJoin() throws Exception {
    int maxIndexIter = atLeast(1);
    int maxSearchIter = atLeast(1);
    executeRandomJoin(true, maxIndexIter, maxSearchIter, TestUtil.nextInt(random(), 11, 57));
  }

  private void executeRandomJoin(
      boolean multipleValuesPerDocument,
      int maxIndexIter,
      int maxSearchIter,
      int numberOfDocumentsToIndex)
      throws Exception {
    for (int indexIter = 1; indexIter <= maxIndexIter; indexIter++) {
      if (VERBOSE) {
        System.out.println("TEST: indexIter=" + indexIter + " numDocs=" + numberOfDocumentsToIndex);
      }
      IndexIterationContext context =
          createContext(numberOfDocumentsToIndex, multipleValuesPerDocument, false);
      IndexSearcher indexSearcher = context.searcher;
      if (VERBOSE) {
        System.out.println("TEST: got searcher=" + indexSearcher);
      }
      for (int searchIter = 1; searchIter <= maxSearchIter; searchIter++) {
        if (VERBOSE) {
          System.out.println("TEST: searchIter=" + searchIter);
        }

        int r = random().nextInt(context.randomUniqueValues.length);
        boolean from = context.randomFrom[r];
        String randomValue = context.randomUniqueValues[r];
        BitSet expectedResult =
            createExpectedResult(randomValue, from, indexSearcher.getIndexReader(), context);

        final Query actualQuery = new TermQuery(new Term("value", randomValue));
        if (VERBOSE) {
          System.out.println("actualQuery=" + actualQuery);
        }
        final ScoreMode scoreMode = ScoreMode.values()[random().nextInt(ScoreMode.values().length)];
        if (VERBOSE) {
          System.out.println("scoreMode=" + scoreMode);
        }

        final Query joinQuery;
        {
          // single val can be handled by multiple-vals
          final boolean multiValsQuery = multipleValuesPerDocument || random().nextBoolean();
          final String fromField = from ? "from" : "to";
          final String toField = from ? "to" : "from";

          int surpriseMe = random().nextInt(2);
          switch (surpriseMe) {
            case 0:
              Class<? extends Number> numType;
              String suffix;
              if (random().nextBoolean()) {
                numType = Integer.class;
                suffix = "INT";
              } else if (random().nextBoolean()) {
                numType = Float.class;
                suffix = "FLOAT";
              } else if (random().nextBoolean()) {
                numType = Long.class;
                suffix = "LONG";
              } else {
                numType = Double.class;
                suffix = "DOUBLE";
              }
              joinQuery =
                  JoinUtil.createJoinQuery(
                      fromField + suffix,
                      multiValsQuery,
                      toField + suffix,
                      numType,
                      actualQuery,
                      indexSearcher,
                      scoreMode);
              break;
            case 1:
              joinQuery =
                  JoinUtil.createJoinQuery(
                      fromField, multiValsQuery, toField, actualQuery, indexSearcher, scoreMode);
              break;
            default:
              throw new RuntimeException("unexpected value " + surpriseMe);
          }
        }
        if (VERBOSE) {
          System.out.println("joinQuery=" + joinQuery);
        }

        // Need to know all documents that have matches. TopDocs doesn't give me that and then I'd
        // be also testing TopDocsCollector...
        Object[] searchResults =
            indexSearcher.search(
                joinQuery,
                new MultiCollectorManager(
                    new BitSetCollectorManager(indexSearcher.getIndexReader().maxDoc()),
                    new TopScoreDocCollectorManager(10, null, Integer.MAX_VALUE)));
        // Asserting bit set...
        assertBitSet(expectedResult, (BitSet) searchResults[0], indexSearcher);
        // Asserting TopDocs...
        TopDocs expectedTopDocs = createExpectedTopDocs(randomValue, from, scoreMode, context);
        TopDocs actualTopDocs = (TopDocs) searchResults[1];
        assertTopDocs(expectedTopDocs, actualTopDocs, scoreMode, indexSearcher, joinQuery);
      }
      context.close();
    }
  }

  private void assertBitSet(BitSet expectedResult, BitSet actualResult, IndexSearcher indexSearcher)
      throws IOException {
    if (VERBOSE) {
      System.out.println("expected cardinality:" + expectedResult.cardinality());
      DocIdSetIterator iterator = new BitSetIterator(expectedResult, expectedResult.cardinality());
      for (int doc = iterator.nextDoc();
          doc != DocIdSetIterator.NO_MORE_DOCS;
          doc = iterator.nextDoc()) {
        System.out.printf(
            Locale.ROOT,
            "Expected doc[%d] with id value %s%n",
            doc,
            indexSearcher.storedFields().document(doc).get("id"));
      }
      System.out.println("actual cardinality:" + actualResult.cardinality());
      iterator = new BitSetIterator(actualResult, actualResult.cardinality());
      for (int doc = iterator.nextDoc();
          doc != DocIdSetIterator.NO_MORE_DOCS;
          doc = iterator.nextDoc()) {
        System.out.printf(
            Locale.ROOT,
            "Actual doc[%d] with id value %s%n",
            doc,
            indexSearcher.storedFields().document(doc).get("id"));
      }
    }
    assertEquals(expectedResult, actualResult);
  }

  private void assertTopDocs(
      TopDocs expectedTopDocs,
      TopDocs actualTopDocs,
      ScoreMode scoreMode,
      IndexSearcher indexSearcher,
      Query joinQuery)
      throws IOException {
    assertEquals(expectedTopDocs.totalHits.value(), actualTopDocs.totalHits.value());
    assertEquals(expectedTopDocs.scoreDocs.length, actualTopDocs.scoreDocs.length);
    if (scoreMode == ScoreMode.None) {
      return;
    }

    if (VERBOSE) {
      for (int i = 0; i < expectedTopDocs.scoreDocs.length; i++) {
        System.out.printf(
            Locale.ENGLISH,
            "Expected doc: %d | Actual doc: %d\n",
            expectedTopDocs.scoreDocs[i].doc,
            actualTopDocs.scoreDocs[i].doc);
        System.out.printf(
            Locale.ENGLISH,
            "Expected score: %f | Actual score: %f\n",
            expectedTopDocs.scoreDocs[i].score,
            actualTopDocs.scoreDocs[i].score);
      }
    }

    for (int i = 0; i < expectedTopDocs.scoreDocs.length; i++) {
      assertEquals(expectedTopDocs.scoreDocs[i].doc, actualTopDocs.scoreDocs[i].doc);
      assertEquals(expectedTopDocs.scoreDocs[i].score, actualTopDocs.scoreDocs[i].score, 0.0f);
      Explanation explanation = indexSearcher.explain(joinQuery, expectedTopDocs.scoreDocs[i].doc);
      assertEquals(expectedTopDocs.scoreDocs[i].score, explanation.getValue().doubleValue(), 0.0f);
    }
  }

  private IndexIterationContext createContext(
      int nDocs, boolean multipleValuesPerDocument, boolean globalOrdinalJoin) throws IOException {
    if (globalOrdinalJoin) {
      assertFalse(
          "ordinal join doesn't support multiple join values per document",
          multipleValuesPerDocument);
    }

    Directory dir = newDirectory();
    final Random random = random();
    RandomIndexWriter w =
        new RandomIndexWriter(
            random,
            dir,
            newIndexWriterConfig(new MockAnalyzer(random, MockTokenizer.KEYWORD, false))
                .setMergePolicy(newMergePolicy(random(), false)));

    IndexIterationContext context = new IndexIterationContext();
    int numRandomValues = nDocs / RandomNumbers.randomIntBetween(random, 1, 4);
    context.randomUniqueValues = new String[numRandomValues];
    Set<String> trackSet = new HashSet<>();
    context.randomFrom = new boolean[numRandomValues];
    for (int i = 0; i < numRandomValues; i++) {
      String uniqueRandomValue;
      do {
        // the trick is to generate values which will be ordered similarly for string, ints&longs,
        // positive nums makes it easier
        //
        // Additionally in order to avoid precision loss when joining via a float field we can't
        // generate values higher than
        // 0xFFFFFF, so we can't use Integer#MAX_VALUE as upper bound here:
        final int nextInt = random.nextInt(0xFFFFFF);
        uniqueRandomValue = String.format(Locale.ROOT, "%08x", nextInt);
        assert nextInt == Integer.parseUnsignedInt(uniqueRandomValue, 16);
      } while (uniqueRandomValue.isEmpty() || trackSet.contains(uniqueRandomValue));

      // Generate unique values and empty strings aren't allowed.
      trackSet.add(uniqueRandomValue);

      context.randomFrom[i] = random.nextBoolean();
      context.randomUniqueValues[i] = uniqueRandomValue;
    }

    List<String> randomUniqueValuesReplica =
        new ArrayList<>(Arrays.asList(context.randomUniqueValues));

    RandomDoc[] docs = new RandomDoc[nDocs];
    for (int i = 0; i < nDocs; i++) {
      String id = Integer.toString(i);
      int randomI = random.nextInt(context.randomUniqueValues.length);
      String value = context.randomUniqueValues[randomI];
      Document document = new Document();
      document.add(newTextField(random, "id", id, Field.Store.YES));
      document.add(newTextField(random, "value", value, Field.Store.NO));

      boolean from = context.randomFrom[randomI];
      int numberOfLinkValues =
          multipleValuesPerDocument
              ? Math.min(2 + random.nextInt(10), context.randomUniqueValues.length)
              : 1;
      docs[i] = new RandomDoc(id, numberOfLinkValues);
      if (globalOrdinalJoin) {
        document.add(newStringField("type", from ? "from" : "to", Field.Store.NO));
      }
      final List<String> subValues;
      {
        int start =
            randomUniqueValuesReplica.size() == numberOfLinkValues
                ? 0
                : random.nextInt(randomUniqueValuesReplica.size() - numberOfLinkValues);
        subValues = randomUniqueValuesReplica.subList(start, start + numberOfLinkValues);
        Collections.shuffle(subValues, random);
      }
      for (String linkValue : subValues) {

        assert !docs[i].linkValues.contains(linkValue);
        docs[i].linkValues.add(linkValue);
        if (from) {
          if (!context.fromDocuments.containsKey(linkValue)) {
            context.fromDocuments.put(linkValue, new ArrayList<>());
          }
          if (!context.randomValueFromDocs.containsKey(value)) {
            context.randomValueFromDocs.put(value, new ArrayList<>());
          }

          context.fromDocuments.get(linkValue).add(docs[i]);
          context.randomValueFromDocs.get(value).add(docs[i]);
          addLinkFields(
              random, document, "from", linkValue, multipleValuesPerDocument, globalOrdinalJoin);

        } else {
          if (!context.toDocuments.containsKey(linkValue)) {
            context.toDocuments.put(linkValue, new ArrayList<>());
          }
          if (!context.randomValueToDocs.containsKey(value)) {
            context.randomValueToDocs.put(value, new ArrayList<>());
          }

          context.toDocuments.get(linkValue).add(docs[i]);
          context.randomValueToDocs.get(value).add(docs[i]);
          addLinkFields(
              random, document, "to", linkValue, multipleValuesPerDocument, globalOrdinalJoin);
        }
      }

      w.addDocument(document);
      if (random.nextInt(10) == 4) {
        w.commit();
      }
      if (VERBOSE) {
        System.out.println("Added document[" + docs[i].id + "]: " + document);
      }
    }

    if (random.nextBoolean()) {
      if (VERBOSE) {
        System.out.println("TEST: now force merge");
      }
      w.forceMerge(1);
    }
    w.close();

    // Pre-compute all possible hits for all unique random values. On top of this also compute all
    // possible score for
    // any ScoreMode.
    DirectoryReader topLevelReader = DirectoryReader.open(dir);
    IndexSearcher searcher = newSearcher(topLevelReader);
    for (int i = 0; i < context.randomUniqueValues.length; i++) {
      String uniqueRandomValue = context.randomUniqueValues[i];
      final String fromField;
      final String toField;
      final Map<String, Map<Integer, JoinScore>> queryVals;
      if (context.randomFrom[i]) {
        fromField = "from";
        toField = "to";
        queryVals = context.fromHitsToJoinScore;
      } else {
        fromField = "to";
        toField = "from";
        queryVals = context.toHitsToJoinScore;
      }
      final Map<BytesRef, JoinScore> joinValueToJoinScores;
      if (multipleValuesPerDocument) {
        joinValueToJoinScores =
            searcher.search(
                new TermQuery(new Term("value", uniqueRandomValue)),
                new CollectorManager<SortedSetJoinScoreCollector, Map<BytesRef, JoinScore>>() {
                  @Override
                  public SortedSetJoinScoreCollector newCollector() {
                    return new SortedSetJoinScoreCollector(fromField);
                  }

                  @Override
                  public Map<BytesRef, JoinScore> reduce(
                      Collection<SortedSetJoinScoreCollector> collectors) {
                    return SortedSetJoinScoreCollector.mergeJoinValueMaps(collectors);
                  }
                });
      } else {
        joinValueToJoinScores =
            searcher.search(
                new TermQuery(new Term("value", uniqueRandomValue)),
                new CollectorManager<
                    SortedDocValuesJoinScoreCollector, Map<BytesRef, JoinScore>>() {
                  @Override
                  public SortedDocValuesJoinScoreCollector newCollector() {
                    return new SortedDocValuesJoinScoreCollector(fromField);
                  }

                  @Override
                  public Map<BytesRef, JoinScore> reduce(
                      Collection<SortedDocValuesJoinScoreCollector> collectors) {
                    return SortedDocValuesJoinScoreCollector.mergeJoinValueMaps(collectors);
                  }
                });
      }

      final Map<Integer, JoinScore> docToJoinScore = new HashMap<>();
      if (multipleValuesPerDocument) {
        Terms terms = MultiTerms.getTerms(topLevelReader, toField);
        if (terms != null) {
          PostingsEnum postingsEnum = null;
          SortedSet<BytesRef> joinValues = new TreeSet<>(joinValueToJoinScores.keySet());
          for (BytesRef joinValue : joinValues) {
            TermsEnum termsEnum = terms.iterator();
            if (termsEnum.seekExact(joinValue)) {
              postingsEnum = termsEnum.postings(postingsEnum, PostingsEnum.NONE);
              JoinScore joinScore = joinValueToJoinScores.get(joinValue);

              for (int doc = postingsEnum.nextDoc();
                  doc != DocIdSetIterator.NO_MORE_DOCS;
                  doc = postingsEnum.nextDoc()) {
                // First encountered join value determines the score.
                // Something to keep in mind for many-to-many relations.
                if (!docToJoinScore.containsKey(doc)) {
                  docToJoinScore.put(doc, joinScore);
                }
              }
            }
          }
        }
      } else {
        docToJoinScore.putAll(
            searcher.search(
                MatchAllDocsQuery.INSTANCE,
                new CollectorManager<DocToJoinScoreCollector, Map<Integer, JoinScore>>() {
                  @Override
                  public DocToJoinScoreCollector newCollector() {
                    return new DocToJoinScoreCollector(toField, joinValueToJoinScores);
                  }

                  @Override
                  public Map<Integer, JoinScore> reduce(
                      Collection<DocToJoinScoreCollector> collectors) {
                    Map<Integer, JoinScore> merged = new HashMap<>();
                    for (DocToJoinScoreCollector c : collectors) {
                      merged.putAll(c.localMap);
                    }
                    return merged;
                  }
                }));
      }
      queryVals.put(uniqueRandomValue, docToJoinScore);
    }

    if (globalOrdinalJoin) {
      SortedDocValues[] values = new SortedDocValues[topLevelReader.leaves().size()];
      for (LeafReaderContext leadContext : topLevelReader.leaves()) {
        values[leadContext.ord] = DocValues.getSorted(leadContext.reader(), "join_field");
      }
      context.ordinalMap = OrdinalMap.build(null, values, PackedInts.DEFAULT);
    }

    context.searcher = searcher;
    context.dir = dir;
    return context;
  }

  private void addLinkFields(
      final Random random,
      Document document,
      final String fieldName,
      String linkValue,
      boolean multipleValuesPerDocument,
      boolean globalOrdinalJoin) {
    document.add(newTextField(random, fieldName, linkValue, Field.Store.NO));

    final int linkInt = Integer.parseUnsignedInt(linkValue, 16);
    document.add(new IntPoint(fieldName + "INT", linkInt));
    document.add(new FloatPoint(fieldName + "FLOAT", linkInt));

    final long linkLong = (long) linkInt << 32 | linkInt;
    document.add(new LongPoint(fieldName + "LONG", linkLong));
    document.add(new DoublePoint(fieldName + "DOUBLE", linkLong));

    if (multipleValuesPerDocument) {
      document.add(new SortedSetDocValuesField(fieldName, new BytesRef(linkValue)));
      document.add(new SortedNumericDocValuesField(fieldName + "INT", linkInt));
      document.add(
          new SortedNumericDocValuesField(fieldName + "FLOAT", Float.floatToRawIntBits(linkInt)));
      document.add(new SortedNumericDocValuesField(fieldName + "LONG", linkLong));
      document.add(
          new SortedNumericDocValuesField(
              fieldName + "DOUBLE", Double.doubleToRawLongBits((double) linkLong)));
    } else {
      document.add(new SortedDocValuesField(fieldName, new BytesRef(linkValue)));
      document.add(new NumericDocValuesField(fieldName + "INT", linkInt));
      document.add(new FloatDocValuesField(fieldName + "FLOAT", linkInt));
      document.add(new NumericDocValuesField(fieldName + "LONG", linkLong));
      document.add(new DoubleDocValuesField(fieldName + "DOUBLE", linkLong));
    }
    if (globalOrdinalJoin) {
      document.add(new SortedDocValuesField("join_field", new BytesRef(linkValue)));
    }
  }

  private TopDocs createExpectedTopDocs(
      String queryValue,
      final boolean from,
      final ScoreMode scoreMode,
      IndexIterationContext context) {

    Map<Integer, JoinScore> hitsToJoinScores;
    if (from) {
      hitsToJoinScores = context.fromHitsToJoinScore.get(queryValue);
    } else {
      hitsToJoinScores = context.toHitsToJoinScore.get(queryValue);
    }
    List<Map.Entry<Integer, JoinScore>> hits = new ArrayList<>(hitsToJoinScores.entrySet());
    hits.sort(
        (hit1, hit2) -> {
          float score1 = hit1.getValue().score(scoreMode);
          float score2 = hit2.getValue().score(scoreMode);

          int cmp = Float.compare(score2, score1);
          if (cmp != 0) {
            return cmp;
          }
          return hit1.getKey() - hit2.getKey();
        });
    ScoreDoc[] scoreDocs = new ScoreDoc[Math.min(10, hits.size())];
    for (int i = 0; i < scoreDocs.length; i++) {
      Map.Entry<Integer, JoinScore> hit = hits.get(i);
      scoreDocs[i] = new ScoreDoc(hit.getKey(), hit.getValue().score(scoreMode));
    }
    return new TopDocs(new TotalHits(hits.size(), TotalHits.Relation.EQUAL_TO), scoreDocs);
  }

  private BitSet createExpectedResult(
      String queryValue, boolean from, IndexReader topLevelReader, IndexIterationContext context)
      throws IOException {
    final Map<String, List<RandomDoc>> randomValueDocs;
    final Map<String, List<RandomDoc>> linkValueDocuments;
    if (from) {
      randomValueDocs = context.randomValueFromDocs;
      linkValueDocuments = context.toDocuments;
    } else {
      randomValueDocs = context.randomValueToDocs;
      linkValueDocuments = context.fromDocuments;
    }

    BitSet expectedResult = new FixedBitSet(topLevelReader.maxDoc());
    List<RandomDoc> matchingDocs = randomValueDocs.get(queryValue);
    if (matchingDocs == null) {
      return new FixedBitSet(topLevelReader.maxDoc());
    }

    for (RandomDoc matchingDoc : matchingDocs) {
      for (String linkValue : matchingDoc.linkValues) {
        List<RandomDoc> otherMatchingDocs = linkValueDocuments.get(linkValue);
        if (otherMatchingDocs == null) {
          continue;
        }

        for (RandomDoc otherSideDoc : otherMatchingDocs) {
          PostingsEnum postingsEnum =
              MultiTerms.getTermPostingsEnum(
                  topLevelReader, "id", new BytesRef(otherSideDoc.id), 0);
          assert postingsEnum != null;
          int doc = postingsEnum.nextDoc();
          expectedResult.set(doc);
        }
      }
    }
    return expectedResult;
  }

  private static class IndexIterationContext {

    String[] randomUniqueValues;
    boolean[] randomFrom;
    Map<String, List<RandomDoc>> fromDocuments = new HashMap<>();
    Map<String, List<RandomDoc>> toDocuments = new HashMap<>();
    Map<String, List<RandomDoc>> randomValueFromDocs = new HashMap<>();
    Map<String, List<RandomDoc>> randomValueToDocs = new HashMap<>();

    Map<String, Map<Integer, JoinScore>> fromHitsToJoinScore = new HashMap<>();
    Map<String, Map<Integer, JoinScore>> toHitsToJoinScore = new HashMap<>();

    OrdinalMap ordinalMap;

    Directory dir;
    IndexSearcher searcher;

    void close() throws IOException {
      searcher.getIndexReader().close();
      dir.close();
    }
  }

  private static class RandomDoc {

    final String id;
    final List<String> linkValues;

    private RandomDoc(String id, int numberOfLinkValues) {
      this.id = id;
      linkValues = new ArrayList<>(numberOfLinkValues);
    }
  }

  private static class JoinScore {

    float minScore = Float.POSITIVE_INFINITY;
    float maxScore = Float.NEGATIVE_INFINITY;
    float total;
    int count;

    void addScore(float score) {
      if (score > maxScore) {
        maxScore = score;
      }
      if (score < minScore) {
        minScore = score;
      }
      total += score;
      count++;
    }

    float score(ScoreMode mode) {
      switch (mode) {
        case None:
          return 1f;
        case Total:
          return total;
        case Avg:
          return total / count;
        case Min:
          return minScore;
        case Max:
          return maxScore;
      }
      throw new IllegalArgumentException("Unsupported ScoreMode: " + mode);
    }
  }

  private static class BitSetCollector extends SimpleCollector {

    private final BitSet bitSet;
    private int docBase;

    private BitSetCollector(BitSet bitSet) {
      this.bitSet = bitSet;
    }

    @Override
    public void collect(int doc) throws IOException {
      bitSet.set(docBase + doc);
    }

    @Override
    protected void doSetNextReader(LeafReaderContext context) throws IOException {
      docBase = context.docBase;
    }

    @Override
    public org.apache.lucene.search.ScoreMode scoreMode() {
      return org.apache.lucene.search.ScoreMode.COMPLETE_NO_SCORES;
    }
  }

  private abstract static class JoinValueCollector extends SimpleCollector {
    final Map<BytesRef, JoinScore> localMap = new HashMap<>();
    final String fromField;
    Scorable scorer;

    JoinValueCollector(String fromField) {
      this.fromField = fromField;
    }

    @Override
    public final void setScorer(Scorable s) {
      this.scorer = s;
    }

    @Override
    public final org.apache.lucene.search.ScoreMode scoreMode() {
      return org.apache.lucene.search.ScoreMode.COMPLETE;
    }

    static Map<BytesRef, JoinScore> mergeJoinValueMaps(
        Collection<? extends JoinValueCollector> collectors) {
      Map<BytesRef, JoinScore> merged = new HashMap<>();
      for (JoinValueCollector c : collectors) {
        for (Map.Entry<BytesRef, JoinScore> entry : c.localMap.entrySet()) {
          merged.merge(
              entry.getKey(),
              entry.getValue(),
              (existing, src) -> {
                if (src.minScore < existing.minScore) existing.minScore = src.minScore;
                if (src.maxScore > existing.maxScore) existing.maxScore = src.maxScore;
                existing.total += src.total;
                existing.count += src.count;
                return existing;
              });
        }
      }
      return merged;
    }
  }

  private static class SortedSetJoinScoreCollector extends JoinValueCollector {
    private SortedSetDocValues docTermOrds;

    SortedSetJoinScoreCollector(String fromField) {
      super(fromField);
    }

    @Override
    public void collect(int doc) throws IOException {
      if (doc > docTermOrds.docID()) {
        docTermOrds.advance(doc);
      }
      if (doc == docTermOrds.docID()) {
        long ord;
        for (int j = 0; j < docTermOrds.docValueCount(); j++) {
          ord = docTermOrds.nextOrd();
          final BytesRef joinValue = docTermOrds.lookupOrd(ord);
          JoinScore joinScore = localMap.get(joinValue);
          if (joinScore == null) {
            localMap.put(BytesRef.deepCopyOf(joinValue), joinScore = new JoinScore());
          }
          joinScore.addScore(scorer.score());
        }
      }
    }

    @Override
    protected void doSetNextReader(LeafReaderContext ctx) throws IOException {
      docTermOrds = DocValues.getSortedSet(ctx.reader(), fromField);
    }
  }

  private static class SortedDocValuesJoinScoreCollector extends JoinValueCollector {
    private SortedDocValues terms;

    SortedDocValuesJoinScoreCollector(String fromField) {
      super(fromField);
    }

    @Override
    public void collect(int doc) throws IOException {
      final BytesRef joinValue;
      if (terms.advanceExact(doc)) {
        joinValue = terms.lookupOrd(terms.ordValue());
      } else {
        return;
      }
      JoinScore joinScore = localMap.get(joinValue);
      if (joinScore == null) {
        localMap.put(BytesRef.deepCopyOf(joinValue), joinScore = new JoinScore());
      }
      if (LuceneTestCase.VERBOSE) {
        System.out.println(
            "expected val=" + joinValue.utf8ToString() + " expected score=" + scorer.score());
      }
      joinScore.addScore(scorer.score());
    }

    @Override
    protected void doSetNextReader(LeafReaderContext ctx) throws IOException {
      terms = DocValues.getSorted(ctx.reader(), fromField);
    }
  }

  private static class DocToJoinScoreCollector extends SimpleCollector {
    final Map<Integer, JoinScore> localMap = new HashMap<>();
    private final String toField;
    private final Map<BytesRef, JoinScore> joinValueToJoinScores;
    private SortedDocValues terms;
    private int docBase;

    DocToJoinScoreCollector(String toField, Map<BytesRef, JoinScore> joinValueToJoinScores) {
      this.toField = toField;
      this.joinValueToJoinScores = joinValueToJoinScores;
    }

    @Override
    public void collect(int doc) throws IOException {
      final BytesRef joinValue;
      if (terms.advanceExact(doc)) {
        joinValue = terms.lookupOrd(terms.ordValue());
      } else {
        joinValue = new BytesRef(BytesRef.EMPTY_BYTES);
      }
      JoinScore joinScore = joinValueToJoinScores.get(joinValue);
      if (joinScore == null) {
        return;
      }
      localMap.put(docBase + doc, joinScore);
    }

    @Override
    protected void doSetNextReader(LeafReaderContext ctx) throws IOException {
      terms = DocValues.getSorted(ctx.reader(), toField);
      docBase = ctx.docBase;
    }

    @Override
    public void setScorer(Scorable scorer) {}

    @Override
    public org.apache.lucene.search.ScoreMode scoreMode() {
      return org.apache.lucene.search.ScoreMode.COMPLETE_NO_SCORES;
    }
  }

  private static class BitSetCollectorManager
      implements CollectorManager<BitSetCollector, FixedBitSet> {

    private final int maxDoc;

    BitSetCollectorManager(int maxDoc) {
      this.maxDoc = maxDoc;
    }

    @Override
    public BitSetCollector newCollector() {
      return new BitSetCollector(new FixedBitSet(maxDoc));
    }

    @Override
    public FixedBitSet reduce(Collection<BitSetCollector> collectors) {
      FixedBitSet result = new FixedBitSet(maxDoc);
      for (BitSetCollector c : collectors) {
        result.or((FixedBitSet) c.bitSet);
      }
      return result;
    }
  }
}
