/*
 * 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 java.io.IOException;
import java.util.Collection;
import java.util.Collections;
import java.util.Locale;
import org.apache.lucene.index.LeafReaderContext;
import org.apache.lucene.search.DocIdSetIterator;
import org.apache.lucene.search.Explanation;
import org.apache.lucene.search.FilterWeight;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.Query;
import org.apache.lucene.search.QueryVisitor;
import org.apache.lucene.search.Scorer;
import org.apache.lucene.search.Weight;
import org.apache.lucene.util.BitSet;

/**
 * Just like {@link ToParentBlockJoinQuery}, except this query joins in reverse: you provide a Query
 * matching parent documents and it joins down to child documents.
 *
 * @lucene.experimental
 */
public class ToChildBlockJoinQuery extends Query {

  /**
   * Message thrown from {@link ToChildBlockJoinScorer#validateParentDoc} on misuse, when the parent
   * query incorrectly returns child docs.
   */
  static final String INVALID_QUERY_MESSAGE =
      "Parent query must not match any docs besides parent filter. "
          + "Combine them as must (+) and must-not (-) clauses to find a problem doc. docID=";

  static final String ILLEGAL_ADVANCE_ON_PARENT =
      "Expect to be advanced on child docs only. got docID=";

  private final BitSetProducer parentsFilter;
  private final Query parentQuery;

  /**
   * Create a ToChildBlockJoinQuery.
   *
   * @param parentQuery Query that matches parent documents
   * @param parentsFilter Filter identifying the parent documents.
   */
  public ToChildBlockJoinQuery(Query parentQuery, BitSetProducer parentsFilter) {
    super();
    this.parentQuery = parentQuery;
    this.parentsFilter = parentsFilter;
  }

  @Override
  public void visit(QueryVisitor visitor) {
    visitor.visitLeaf(this);
  }

  @Override
  public Weight createWeight(
      IndexSearcher searcher, org.apache.lucene.search.ScoreMode scoreMode, float boost)
      throws IOException {
    return new ToChildBlockJoinWeight(
        this,
        parentQuery.createWeight(searcher, scoreMode, boost),
        parentsFilter,
        scoreMode.needsScores());
  }

  /** Return our parent query. */
  public Query getParentQuery() {
    return parentQuery;
  }

  private static class ToChildBlockJoinWeight extends FilterWeight {
    private final BitSetProducer parentsFilter;
    private final boolean doScores;

    public ToChildBlockJoinWeight(
        Query joinQuery, Weight parentWeight, BitSetProducer parentsFilter, boolean doScores) {
      super(joinQuery, parentWeight);
      this.parentsFilter = parentsFilter;
      this.doScores = doScores;
    }

    // NOTE: acceptDocs applies (and is checked) only in the
    // child document space
    @Override
    public Scorer scorer(LeafReaderContext readerContext) throws IOException {

      final Scorer parentScorer = in.scorer(readerContext);

      if (parentScorer == null) {
        // No matches
        return null;
      }

      // NOTE: this doesn't take acceptDocs into account, the responsibility
      // to not match deleted docs is on the scorer
      final BitSet parents = parentsFilter.getBitSet(readerContext);
      if (parents == null) {
        // No parents
        return null;
      }

      return new ToChildBlockJoinScorer(this, parentScorer, parents, doScores);
    }

    @Override
    public Explanation explain(LeafReaderContext context, int doc) throws IOException {
      ToChildBlockJoinScorer scorer = (ToChildBlockJoinScorer) scorer(context);
      if (scorer != null && scorer.iterator().advance(doc) == doc) {
        int parentDoc = scorer.getParentDoc();
        return Explanation.match(
            scorer.score(),
            String.format(
                Locale.ROOT, "Score based on parent document %d", parentDoc + context.docBase),
            in.explain(context, parentDoc));
      }
      return Explanation.noMatch("Not a match");
    }
  }

  static class ToChildBlockJoinScorer extends Scorer {
    private final Scorer parentScorer;
    private final DocIdSetIterator parentIt;
    private final BitSet parentBits;
    private final boolean doScores;

    private float parentScore;

    private int childDoc = -1;
    private int parentDoc = 0;

    public ToChildBlockJoinScorer(
        Weight weight, Scorer parentScorer, BitSet parentBits, boolean doScores) {
      super(weight);
      this.doScores = doScores;
      this.parentBits = parentBits;
      this.parentScorer = parentScorer;
      this.parentIt = parentScorer.iterator();
    }

    @Override
    public Collection<ChildScorable> getChildren() {
      return Collections.singleton(new ChildScorable(parentScorer, "BLOCK_JOIN"));
    }

    @Override
    public DocIdSetIterator iterator() {
      return new DocIdSetIterator() {

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

        @Override
        public int nextDoc() throws IOException {
          // System.out.println("Q.nextDoc() parentDoc=" + parentDoc + " childDoc=" + childDoc);

          while (true) {
            if (childDoc + 1 == parentDoc) {
              // OK, we are done iterating through all children
              // matching this one parent doc, so we now nextDoc()
              // the parent.  Use a while loop because we may have
              // to skip over some number of parents w/ no
              // children:
              while (true) {
                parentDoc = parentIt.nextDoc();
                validateParentDoc();

                if (parentDoc == 0) {
                  // Degenerate but allowed: first parent doc has no children
                  // TODO: would be nice to pull initial parent
                  // into ctor so we can skip this if... but it's
                  // tricky because scorer must return -1 for
                  // .doc() on init...
                  parentDoc = parentIt.nextDoc();
                  validateParentDoc();
                }

                if (parentDoc == NO_MORE_DOCS) {
                  childDoc = NO_MORE_DOCS;
                  // System.out.println("  END");
                  return childDoc;
                }

                // Go to first child for this next parentDoc:
                childDoc = 1 + parentBits.prevSetBit(parentDoc - 1);

                if (childDoc == parentDoc) {
                  // This parent has no children; continue
                  // parent loop so we move to next parent
                  continue;
                }

                if (childDoc < parentDoc) {
                  if (doScores) {
                    parentScore = parentScorer.score();
                  }
                  // System.out.println("  " + childDoc);
                  return childDoc;
                } else {
                  // Degenerate but allowed: parent has no children
                }
              }
            } else {
              assert childDoc < parentDoc : "childDoc=" + childDoc + " parentDoc=" + parentDoc;
              childDoc++;
              // System.out.println("  " + childDoc);
              return childDoc;
            }
          }
        }

        @Override
        public int advance(int childTarget) throws IOException {
          if (childTarget >= parentDoc) {
            if (childTarget == NO_MORE_DOCS) {
              return childDoc = parentDoc = NO_MORE_DOCS;
            }
            parentDoc = parentIt.advance(childTarget + 1);
            validateParentDoc();

            if (parentDoc == NO_MORE_DOCS) {
              return childDoc = NO_MORE_DOCS;
            }

            // scan to the first parent that has children
            while (true) {
              final int firstChild = parentBits.prevSetBit(parentDoc - 1) + 1;
              if (firstChild != parentDoc) {
                // this parent has children
                childTarget = Math.max(childTarget, firstChild);
                break;
              }
              // parent with no children, move to the next one
              parentDoc = parentIt.nextDoc();
              validateParentDoc();
              if (parentDoc == NO_MORE_DOCS) {
                return childDoc = NO_MORE_DOCS;
              }
            }

            if (doScores) {
              parentScore = parentScorer.score();
            }
          }

          assert childTarget < parentDoc;
          assert !parentBits.get(childTarget);
          childDoc = childTarget;
          // System.out.println("  " + childDoc);
          return childDoc;
        }

        @Override
        public long cost() {
          return parentIt.cost();
        }
      };
    }

    /** Detect mis-use, where provided parent query in fact sometimes returns child documents. */
    private void validateParentDoc() {
      if (parentDoc != DocIdSetIterator.NO_MORE_DOCS && !parentBits.get(parentDoc)) {
        throw new IllegalStateException(INVALID_QUERY_MESSAGE + parentDoc);
      }
    }

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

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

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

    int getParentDoc() {
      return parentDoc;
    }
  }

  @Override
  public Query rewrite(IndexSearcher indexSearcher) throws IOException {
    final Query parentRewrite = parentQuery.rewrite(indexSearcher);
    if (parentRewrite != parentQuery) {
      return new ToChildBlockJoinQuery(parentRewrite, parentsFilter);
    } else {
      return super.rewrite(indexSearcher);
    }
  }

  @Override
  public String toString(String field) {
    return "ToChildBlockJoinQuery (" + parentQuery.toString() + ")";
  }

  @Override
  public boolean equals(Object other) {
    return sameClassAs(other) && equalsTo(getClass().cast(other));
  }

  private boolean equalsTo(ToChildBlockJoinQuery other) {
    return parentQuery.equals(other.parentQuery) && parentsFilter.equals(other.parentsFilter);
  }

  @Override
  public int hashCode() {
    final int prime = 31;
    int hash = classHash();
    hash = prime * hash + parentQuery.hashCode();
    hash = prime * hash + parentsFilter.hashCode();
    return hash;
  }
}
