/*
 * ModeShape (http://www.modeshape.org)
 *
 * Licensed 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.modeshape.jcr.index.lucene.query;

import static org.modeshape.jcr.value.ValueComparators.STRING_COMPARATOR;

import java.util.Objects;
import java.util.function.BiPredicate;
import java.util.function.Function;
import java.util.regex.Pattern;
import javax.jcr.query.qom.Comparison;
import org.apache.lucene.index.LeafReaderContext;
import org.apache.lucene.index.Term;
import org.apache.lucene.search.Query;
import org.apache.lucene.search.TermQuery;
import org.apache.lucene.search.TermRangeQuery;
import org.apache.lucene.search.Weight;
import org.apache.lucene.search.WildcardQuery;
import org.apache.lucene.util.BytesRef;
import org.modeshape.common.annotation.Immutable;
import org.modeshape.jcr.query.engine.QueryUtil;

/**
 * A Lucene {@link Query} implementation that is used to apply a {@link Comparison} constraint against a string field. This query
 * implementation works by using the weight and {@link Weight#scorer(LeafReaderContext)}  scorer} of the wrapped query
 * to score (and return) only those documents with string fields that satisfy the constraint.
 */
@Immutable
public class CompareStringQuery extends CompareQuery<String> {
    
    /**
     * Construct a {@link Query} implementation that scores nodes according to the supplied comparator.
     *
     * @param fieldName the name of the document field containing the value; may not be null
     * @param constraintValue the constraint value; may not be null
     * @param evaluator the {@link BiPredicate} implementation that returns whether the node path satisfies the
     * @param caseOperation a {@link Function} which can be applied to perform the comparison in a different case; may be null
     */
    protected CompareStringQuery(String fieldName,
                                 String constraintValue,
                                 BiPredicate<String, String> evaluator,
                                 Function<String, String> caseOperation) {
        super(fieldName, constraintValue, evaluator, caseOperation);
    }
    
    @Override
    protected String convertValue(String casedValue) {
        // return the value as-is (no conversion needed)
        return casedValue;
    }
    
    @Override
    public Query clone() {
        return new CompareStringQuery(field(), constraintValue, evaluator, caseOperation);
    }
    
    /**
     * Construct a {@link Query} implementation that scores documents with a string field value that is equal to the supplied
     * constraint value.
     *
     * @param constraintValue the constraint value; may not be null
     * @param fieldName the name of the document field containing the value; may not be null
     * @param caseOperation the operation that should be performed on the indexed values before the constraint value is being
     *        evaluated; may be null which indicates that no case conversion should be done
     * @return the query; never null
     */
    public static Query createQueryForNodesWithFieldEqualTo(String constraintValue,
                                                            String fieldName,
                                                            Function<String, String> caseOperation) {
        if (caseOperation == null) {
            // no need to process the stored index values, so we can use a default Lucene query
            return new TermQuery(new Term(fieldName, constraintValue));
        }
        return new CompareStringQuery(fieldName, constraintValue, Objects::equals, caseOperation);
    }
    
    /**
     * Construct a {@link Query} implementation that scores documents with a string field value that is greater than the supplied
     * constraint value.
     *
     * @param constraintValue the constraint value; may not be null
     * @param fieldName the name of the document field containing the value; may not be null
     * @param caseOperation the operation that should be performed on the indexed values before the constraint value is being
     *        evaluated; may be null which indicates that no case conversion should be done
     * @return the query; never null
     */
    public static Query createQueryForNodesWithFieldGreaterThan(String constraintValue,
                                                                String fieldName,
                                                                Function<String, String> caseOperation) {
        if (caseOperation == null) {
            // no need to process the stored index values, so we can use a default Lucene query
            return new TermRangeQuery(fieldName, new BytesRef(constraintValue), null, false, false);
        }
        return new CompareStringQuery(fieldName, constraintValue,
                                      (s1, s2) -> STRING_COMPARATOR.compare(s1, s2) > 0, caseOperation);
    }
    
    /**
     * Construct a {@link Query} implementation that scores documents with a string field value that is greater than or equal to
     * the supplied constraint value.
     *
     * @param constraintValue the constraint value; may not be null
     * @param fieldName the name of the document field containing the value; may not be null
     * @param caseOperation the operation that should be performed on the indexed values before the constraint value is being
     *        evaluated; may be null which indicates that no case conversion should be done
     * @return the query; never null
     */
    public static Query createQueryForNodesWithFieldGreaterThanOrEqualTo(String constraintValue,
                                                                         String fieldName,
                                                                         Function<String, String> caseOperation) {
        if (caseOperation == null) {
            // no need to process the stored index values, so we can use a default Lucene query
            return new TermRangeQuery(fieldName, new BytesRef(constraintValue), null, true, false);
        }
        return new CompareStringQuery(fieldName, constraintValue,
                                      (s1, s2) -> STRING_COMPARATOR.compare(s1, s2) >= 0, caseOperation);
    }
    
    /**
     * Construct a {@link Query} implementation that scores documents with a string field value that is less than the supplied
     * constraint value.
     *
     * @param constraintValue the constraint value; may not be null
     * @param fieldName the name of the document field containing the value; may not be null
     * @param caseOperation the operation that should be performed on the indexed values before the constraint value is being
     *        evaluated; may be null which indicates that no case conversion should be done
     * @return the query; never null
     */
    public static Query createQueryForNodesWithFieldLessThan(String constraintValue,
                                                             String fieldName,
                                                             Function<String, String> caseOperation) {
        if (caseOperation == null) {
            // no need to process the stored index values, so we can use a default Lucene query
            return new TermRangeQuery(fieldName, null, new BytesRef(constraintValue), false, false);
        }
        return new CompareStringQuery(fieldName, constraintValue,
                                      (s1, s2) -> STRING_COMPARATOR.compare(s1, s2) < 0, caseOperation);
    }
    
    /**
     * Construct a {@link Query} implementation that scores documents with a string field value that is less than or equal to the
     * supplied constraint value.
     *
     * @param constraintValue the constraint value; may not be null
     * @param fieldName the name of the document field containing the value; may not be null
     * @param caseOperation the operation that should be performed on the indexed values before the constraint value is being
     *        evaluated; may be null which indicates that no case conversion should be done
     * @return the query; never null
     */
    public static Query createQueryForNodesWithFieldLessThanOrEqualTo(String constraintValue,
                                                                      String fieldName,
                                                                      Function<String, String> caseOperation) {
        if (caseOperation == null) {
            // no need to process the stored index values, so we can use a default Lucene query
            return new TermRangeQuery(fieldName, null, new BytesRef(constraintValue), true, true);
        }
        return new CompareStringQuery(fieldName, constraintValue,
                                      (s1, s2) -> STRING_COMPARATOR.compare(s1, s2) <= 0, caseOperation);
    }
    
    /**
     * Construct a {@link Query} implementation that scores documents with a string field value that is LIKE the supplied
     * constraint value, where the LIKE expression contains the SQL wildcard characters '%' and '_' or the regular expression
     * wildcard characters '*' and '?'.
     *
     * @param likeExpression the LIKE expression; may not be null
     * @param fieldName the name of the document field containing the value; may not be null
     * @param caseOperation the operation that should be performed on the indexed values before the constraint value is being
     *        evaluated; may be null which indicates that no case conversion should be done
     * @return the query; never null
     */
    protected static Query createQueryForNodesWithFieldLike(String likeExpression,
                                                            String fieldName,
                                                            Function<String, String> caseOperation) {
        assert likeExpression != null;
        assert likeExpression.length() > 0;
        
        if (!QueryUtil.hasWildcardCharacters(likeExpression)) {
            // This is not a like expression, so just do an equals ...
            return createQueryForNodesWithFieldEqualTo(likeExpression, fieldName, caseOperation);
        }
        if (caseOperation == null) {
            // We can just do a normal Wildcard query ...
            
            // '%' matches 0 or more characters
            // '_' matches any single character
            // '\x' matches 'x'
            // all other characters match themselves
            
            // Wildcard queries are a better match, but they can be slow and should not be used
            // if the first character of the expression is a '%' or '_' or '*' or '?' ...
            char firstChar = likeExpression.charAt(0);
            if (firstChar != '%' && firstChar != '_' && firstChar != '*' && firstChar != '?') {
                // Create a wildcard query ...
                String expression = toWildcardExpression(likeExpression);
                return new WildcardQuery(new Term(fieldName, expression));
            }
        }
        // Create a regex query...
        String regex = QueryUtil.toRegularExpression(likeExpression);
        Pattern pattern = Pattern.compile(regex, Pattern.UNICODE_CASE);
        return new RegexQuery(fieldName, pattern, caseOperation);
    }
    
    /**
     * Convert the JCR like expression to a Lucene wildcard expression. The JCR like expression uses '%' to match 0 or more
     * characters, '_' to match any single character, '\x' to match the 'x' character, and all other characters to match
     * themselves.
     *
     * @param likeExpression the like expression; may not be null
     * @return the expression that can be used with a WildcardQuery; never null
     */
    protected static String toWildcardExpression( String likeExpression ) {
        return likeExpression.replace('%', '*').replace('_', '?').replaceAll("\\\\(.)", "$1");
    }
}
