/*
 * Copyright 2020 The Android Open Source Project
 *
 * 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 android.app.appsearch;

import android.annotation.IntDef;
import android.annotation.IntRange;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.annotation.SuppressLint;
import android.app.appsearch.annotation.CanIgnoreReturnValue;
import android.app.appsearch.exceptions.AppSearchException;
import android.app.appsearch.util.BundleUtil;
import android.os.Bundle;
import android.util.ArrayMap;
import android.util.ArraySet;

import com.android.internal.util.Preconditions;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;

/**
 * This class represents the specification logic for AppSearch. It can be used to set the type of
 * search, like prefix or exact only or apply filters to search for a specific schema type only etc.
 */
public final class SearchSpec {
    /**
     * Schema type to be used in {@link SearchSpec.Builder#addProjection} to apply property paths to
     * all results, excepting any types that have had their own, specific property paths set.
     */
    public static final String PROJECTION_SCHEMA_TYPE_WILDCARD = "*";

    static final String TERM_MATCH_TYPE_FIELD = "termMatchType";
    static final String SCHEMA_FIELD = "schema";
    static final String NAMESPACE_FIELD = "namespace";
    static final String PACKAGE_NAME_FIELD = "packageName";
    static final String NUM_PER_PAGE_FIELD = "numPerPage";
    static final String RANKING_STRATEGY_FIELD = "rankingStrategy";
    static final String ORDER_FIELD = "order";
    static final String SNIPPET_COUNT_FIELD = "snippetCount";
    static final String SNIPPET_COUNT_PER_PROPERTY_FIELD = "snippetCountPerProperty";
    static final String MAX_SNIPPET_FIELD = "maxSnippet";
    static final String PROJECTION_TYPE_PROPERTY_PATHS_FIELD = "projectionTypeFieldMasks";
    static final String RESULT_GROUPING_TYPE_FLAGS = "resultGroupingTypeFlags";
    static final String RESULT_GROUPING_LIMIT = "resultGroupingLimit";
    static final String TYPE_PROPERTY_WEIGHTS_FIELD = "typePropertyWeightsField";
    static final String JOIN_SPEC = "joinSpec";
    static final String ADVANCED_RANKING_EXPRESSION = "advancedRankingExpression";
    static final String ENABLED_FEATURES_FIELD = "enabledFeatures";

    /** @hide */
    public static final int DEFAULT_NUM_PER_PAGE = 10;

    // TODO(b/170371356): In framework, we may want these limits to be flag controlled.
    //  If that happens, the @IntRange() directives in this class may have to change.
    private static final int MAX_NUM_PER_PAGE = 10_000;
    private static final int MAX_SNIPPET_COUNT = 10_000;
    private static final int MAX_SNIPPET_PER_PROPERTY_COUNT = 10_000;
    private static final int MAX_SNIPPET_SIZE_LIMIT = 10_000;

    /**
     * Term Match Type for the query.
     *
     * @hide
     */
    // NOTE: The integer values of these constants must match the proto enum constants in
    // {@link com.google.android.icing.proto.SearchSpecProto.termMatchType}
    @IntDef(value = {TERM_MATCH_EXACT_ONLY, TERM_MATCH_PREFIX})
    @Retention(RetentionPolicy.SOURCE)
    public @interface TermMatch {}

    /**
     * Query terms will only match exact tokens in the index.
     *
     * <p>Ex. A query term "foo" will only match indexed token "foo", and not "foot" or "football".
     */
    public static final int TERM_MATCH_EXACT_ONLY = 1;
    /**
     * Query terms will match indexed tokens when the query term is a prefix of the token.
     *
     * <p>Ex. A query term "foo" will match indexed tokens like "foo", "foot", and "football".
     */
    public static final int TERM_MATCH_PREFIX = 2;

    /**
     * Ranking Strategy for query result.
     *
     * @hide
     */
    // NOTE: The integer values of these constants must match the proto enum constants in
    // {@link ScoringSpecProto.RankingStrategy.Code}
    @IntDef(
            value = {
                RANKING_STRATEGY_NONE,
                RANKING_STRATEGY_DOCUMENT_SCORE,
                RANKING_STRATEGY_CREATION_TIMESTAMP,
                RANKING_STRATEGY_RELEVANCE_SCORE,
                RANKING_STRATEGY_USAGE_COUNT,
                RANKING_STRATEGY_USAGE_LAST_USED_TIMESTAMP,
                RANKING_STRATEGY_SYSTEM_USAGE_COUNT,
                RANKING_STRATEGY_SYSTEM_USAGE_LAST_USED_TIMESTAMP,
                RANKING_STRATEGY_JOIN_AGGREGATE_SCORE,
                RANKING_STRATEGY_ADVANCED_RANKING_EXPRESSION,
            })
    @Retention(RetentionPolicy.SOURCE)
    public @interface RankingStrategy {}

    /** No Ranking, results are returned in arbitrary order. */
    public static final int RANKING_STRATEGY_NONE = 0;
    /** Ranked by app-provided document scores. */
    public static final int RANKING_STRATEGY_DOCUMENT_SCORE = 1;
    /** Ranked by document creation timestamps. */
    public static final int RANKING_STRATEGY_CREATION_TIMESTAMP = 2;
    /** Ranked by document relevance score. */
    public static final int RANKING_STRATEGY_RELEVANCE_SCORE = 3;
    /** Ranked by number of usages, as reported by the app. */
    public static final int RANKING_STRATEGY_USAGE_COUNT = 4;
    /** Ranked by timestamp of last usage, as reported by the app. */
    public static final int RANKING_STRATEGY_USAGE_LAST_USED_TIMESTAMP = 5;
    /** Ranked by number of usages from a system UI surface. */
    public static final int RANKING_STRATEGY_SYSTEM_USAGE_COUNT = 6;
    /** Ranked by timestamp of last usage from a system UI surface. */
    public static final int RANKING_STRATEGY_SYSTEM_USAGE_LAST_USED_TIMESTAMP = 7;
    /**
     * Ranked by the aggregated ranking signal of the joined documents.
     *
     * <p>Which aggregation strategy is used to determine a ranking signal is specified in the
     * {@link JoinSpec} set by {@link Builder#setJoinSpec}. This ranking strategy may not be used if
     * no {@link JoinSpec} is provided.
     *
     * @see Builder#build
     */
    public static final int RANKING_STRATEGY_JOIN_AGGREGATE_SCORE = 8;
    /** Ranked by the advanced ranking expression provided. */
    public static final int RANKING_STRATEGY_ADVANCED_RANKING_EXPRESSION = 9;

    /**
     * Order for query result.
     *
     * @hide
     */
    // NOTE: The integer values of these constants must match the proto enum constants in
    // {@link ScoringSpecProto.Order.Code}
    @IntDef(value = {ORDER_DESCENDING, ORDER_ASCENDING})
    @Retention(RetentionPolicy.SOURCE)
    public @interface Order {}

    /** Search results will be returned in a descending order. */
    public static final int ORDER_DESCENDING = 0;
    /** Search results will be returned in an ascending order. */
    public static final int ORDER_ASCENDING = 1;

    /**
     * Grouping type for result limits.
     *
     * @hide
     */
    @IntDef(
            flag = true,
            value = {
                GROUPING_TYPE_PER_PACKAGE,
                GROUPING_TYPE_PER_NAMESPACE,
                GROUPING_TYPE_PER_SCHEMA
            })
    @Retention(RetentionPolicy.SOURCE)
    public @interface GroupingType {}
    /**
     * Results should be grouped together by package for the purpose of enforcing a limit on the
     * number of results returned per package.
     */
    public static final int GROUPING_TYPE_PER_PACKAGE = 1 << 0;
    /**
     * Results should be grouped together by namespace for the purpose of enforcing a limit on the
     * number of results returned per namespace.
     */
    public static final int GROUPING_TYPE_PER_NAMESPACE = 1 << 1;
    /**
     * Results should be grouped together by schema type for the purpose of enforcing a limit on the
     * number of results returned per schema type.
     * @hide
     */
    public static final int GROUPING_TYPE_PER_SCHEMA = 1 << 2;

    private final Bundle mBundle;

    /** @hide */
    public SearchSpec(@NonNull Bundle bundle) {
        Objects.requireNonNull(bundle);
        mBundle = bundle;
    }

    /**
     * Returns the {@link Bundle} populated by this builder.
     *
     * @hide
     */
    @NonNull
    public Bundle getBundle() {
        return mBundle;
    }

    /** Returns how the query terms should match terms in the index. */
    @TermMatch
    public int getTermMatch() {
        return mBundle.getInt(TERM_MATCH_TYPE_FIELD, -1);
    }

    /**
     * Returns the list of schema types to search for.
     *
     * <p>If empty, the query will search over all schema types.
     */
    @NonNull
    public List<String> getFilterSchemas() {
        List<String> schemas = mBundle.getStringArrayList(SCHEMA_FIELD);
        if (schemas == null) {
            return Collections.emptyList();
        }
        return Collections.unmodifiableList(schemas);
    }

    /**
     * Returns the list of namespaces to search over.
     *
     * <p>If empty, the query will search over all namespaces.
     */
    @NonNull
    public List<String> getFilterNamespaces() {
        List<String> namespaces = mBundle.getStringArrayList(NAMESPACE_FIELD);
        if (namespaces == null) {
            return Collections.emptyList();
        }
        return Collections.unmodifiableList(namespaces);
    }

    /**
     * Returns the list of package name filters to search over.
     *
     * <p>If empty, the query will search over all packages that the caller has access to. If
     * package names are specified which caller doesn't have access to, then those package names
     * will be ignored.
     */
    @NonNull
    public List<String> getFilterPackageNames() {
        List<String> packageNames = mBundle.getStringArrayList(PACKAGE_NAME_FIELD);
        if (packageNames == null) {
            return Collections.emptyList();
        }
        return Collections.unmodifiableList(packageNames);
    }

    /** Returns the number of results per page in the result set. */
    public int getResultCountPerPage() {
        return mBundle.getInt(NUM_PER_PAGE_FIELD, DEFAULT_NUM_PER_PAGE);
    }

    /** Returns the ranking strategy. */
    @RankingStrategy
    public int getRankingStrategy() {
        return mBundle.getInt(RANKING_STRATEGY_FIELD);
    }

    /** Returns the order of returned search results (descending or ascending). */
    @Order
    public int getOrder() {
        return mBundle.getInt(ORDER_FIELD);
    }

    /** Returns how many documents to generate snippets for. */
    public int getSnippetCount() {
        return mBundle.getInt(SNIPPET_COUNT_FIELD);
    }

    /**
     * Returns how many matches for each property of a matching document to generate snippets for.
     */
    public int getSnippetCountPerProperty() {
        return mBundle.getInt(SNIPPET_COUNT_PER_PROPERTY_FIELD);
    }

    /** Returns the maximum size of a snippet in characters. */
    public int getMaxSnippetSize() {
        return mBundle.getInt(MAX_SNIPPET_FIELD);
    }

    /**
     * Returns a map from schema type to property paths to be used for projection.
     *
     * <p>If the map is empty, then all properties will be retrieved for all results.
     *
     * <p>Calling this function repeatedly is inefficient. Prefer to retain the Map returned by this
     * function, rather than calling it multiple times.
     *
     * @return A mapping of schema types to lists of projection strings.
     */
    @NonNull
    public Map<String, List<String>> getProjections() {
        Bundle typePropertyPathsBundle =
                Objects.requireNonNull(mBundle.getBundle(PROJECTION_TYPE_PROPERTY_PATHS_FIELD));
        Set<String> schemas = typePropertyPathsBundle.keySet();
        Map<String, List<String>> typePropertyPathsMap = new ArrayMap<>(schemas.size());
        for (String schema : schemas) {
            typePropertyPathsMap.put(
                    schema,
                    Objects.requireNonNull(typePropertyPathsBundle.getStringArrayList(schema)));
        }
        return typePropertyPathsMap;
    }

    /**
     * Returns a map from schema type to property paths to be used for projection.
     *
     * <p>If the map is empty, then all properties will be retrieved for all results.
     *
     * <p>Calling this function repeatedly is inefficient. Prefer to retain the Map returned by this
     * function, rather than calling it multiple times.
     *
     * @return A mapping of schema types to lists of projection {@link PropertyPath} objects.
     */
    @NonNull
    public Map<String, List<PropertyPath>> getProjectionPaths() {
        Bundle typePropertyPathsBundle = mBundle.getBundle(PROJECTION_TYPE_PROPERTY_PATHS_FIELD);
        Set<String> schemas = typePropertyPathsBundle.keySet();
        Map<String, List<PropertyPath>> typePropertyPathsMap = new ArrayMap<>(schemas.size());
        for (String schema : schemas) {
            ArrayList<String> propertyPathList = typePropertyPathsBundle.getStringArrayList(schema);
            List<PropertyPath> copy = new ArrayList<>(propertyPathList.size());
            for (String p : propertyPathList) {
                copy.add(new PropertyPath(p));
            }
            typePropertyPathsMap.put(schema, copy);
        }
        return typePropertyPathsMap;
    }

    /**
     * Returns properties weights to be used for scoring.
     *
     * <p>Calling this function repeatedly is inefficient. Prefer to retain the {@link Map} returned
     * by this function, rather than calling it multiple times.
     *
     * @return a {@link Map} of schema type to an inner-map of property paths of the schema type to
     *     the weight to set for that property.
     */
    @NonNull
    public Map<String, Map<String, Double>> getPropertyWeights() {
        Bundle typePropertyWeightsBundle = mBundle.getBundle(TYPE_PROPERTY_WEIGHTS_FIELD);
        Set<String> schemaTypes = typePropertyWeightsBundle.keySet();
        Map<String, Map<String, Double>> typePropertyWeightsMap =
                new ArrayMap<>(schemaTypes.size());
        for (String schemaType : schemaTypes) {
            Bundle propertyPathBundle = typePropertyWeightsBundle.getBundle(schemaType);
            Set<String> propertyPaths = propertyPathBundle.keySet();
            Map<String, Double> propertyPathWeights = new ArrayMap<>(propertyPaths.size());
            for (String propertyPath : propertyPaths) {
                propertyPathWeights.put(propertyPath, propertyPathBundle.getDouble(propertyPath));
            }
            typePropertyWeightsMap.put(schemaType, propertyPathWeights);
        }
        return typePropertyWeightsMap;
    }

    /**
     * Returns properties weights to be used for scoring.
     *
     * <p>Calling this function repeatedly is inefficient. Prefer to retain the {@link Map} returned
     * by this function, rather than calling it multiple times.
     *
     * @return a {@link Map} of schema type to an inner-map of property paths of the schema type to
     *     the weight to set for that property.
     */
    @NonNull
    public Map<String, Map<PropertyPath, Double>> getPropertyWeightPaths() {
        Bundle typePropertyWeightsBundle = mBundle.getBundle(TYPE_PROPERTY_WEIGHTS_FIELD);
        Set<String> schemaTypes = typePropertyWeightsBundle.keySet();
        Map<String, Map<PropertyPath, Double>> typePropertyWeightsMap =
                new ArrayMap<>(schemaTypes.size());
        for (String schemaType : schemaTypes) {
            Bundle propertyPathBundle = typePropertyWeightsBundle.getBundle(schemaType);
            Set<String> propertyPaths = propertyPathBundle.keySet();
            Map<PropertyPath, Double> propertyPathWeights = new ArrayMap<>(propertyPaths.size());
            for (String propertyPath : propertyPaths) {
                propertyPathWeights.put(
                        new PropertyPath(propertyPath), propertyPathBundle.getDouble(propertyPath));
            }
            typePropertyWeightsMap.put(schemaType, propertyPathWeights);
        }
        return typePropertyWeightsMap;
    }

    /**
     * Get the type of grouping limit to apply, or 0 if {@link Builder#setResultGrouping} was not
     * called.
     */
    @GroupingType
    public int getResultGroupingTypeFlags() {
        return mBundle.getInt(RESULT_GROUPING_TYPE_FLAGS);
    }

    /**
     * Get the maximum number of results to return for each group.
     *
     * @return the maximum number of results to return for each group or Integer.MAX_VALUE if {@link
     *     Builder#setResultGrouping(int, int)} was not called.
     */
    public int getResultGroupingLimit() {
        return mBundle.getInt(RESULT_GROUPING_LIMIT, Integer.MAX_VALUE);
    }

    /** Returns specification on which documents need to be joined. */
    @Nullable
    public JoinSpec getJoinSpec() {
        Bundle joinSpec = mBundle.getBundle(JOIN_SPEC);
        if (joinSpec == null) {
            return null;
        }
        return new JoinSpec(joinSpec);
    }

    /**
     * Get the advanced ranking expression, or "" if {@link Builder#setRankingStrategy(String)} was
     * not called.
     */
    @NonNull
    public String getAdvancedRankingExpression() {
        return mBundle.getString(ADVANCED_RANKING_EXPRESSION, "");
    }

    /** Returns whether the {@link Features#NUMERIC_SEARCH} feature is enabled. */
    public boolean isNumericSearchEnabled() {
        return getEnabledFeatures().contains(FeatureConstants.NUMERIC_SEARCH);
    }

    /** Returns whether the {@link Features#VERBATIM_SEARCH} feature is enabled. */
    public boolean isVerbatimSearchEnabled() {
        return getEnabledFeatures().contains(FeatureConstants.VERBATIM_SEARCH);
    }

    /** Returns whether the {@link Features#LIST_FILTER_QUERY_LANGUAGE} feature is enabled. */
    public boolean isListFilterQueryLanguageEnabled() {
        return getEnabledFeatures().contains(FeatureConstants.LIST_FILTER_QUERY_LANGUAGE);
    }

    /**
     * Get the list of enabled features that the caller is intending to use in this search call.
     *
     * @return the set of {@link Features} enabled in this {@link SearchSpec} Entry.
     * @hide
     */
    @NonNull
    public List<String> getEnabledFeatures() {
        return mBundle.getStringArrayList(ENABLED_FEATURES_FIELD);
    }

    /** Builder for {@link SearchSpec objects}. */
    public static final class Builder {
        private ArrayList<String> mSchemas = new ArrayList<>();
        private ArrayList<String> mNamespaces = new ArrayList<>();
        private ArrayList<String> mPackageNames = new ArrayList<>();
        private ArraySet<String> mEnabledFeatures = new ArraySet<>();
        private Bundle mProjectionTypePropertyMasks = new Bundle();
        private Bundle mTypePropertyWeights = new Bundle();

        private int mResultCountPerPage = DEFAULT_NUM_PER_PAGE;
        @TermMatch private int mTermMatchType = TERM_MATCH_PREFIX;
        private int mSnippetCount = 0;
        private int mSnippetCountPerProperty = MAX_SNIPPET_PER_PROPERTY_COUNT;
        private int mMaxSnippetSize = 0;
        @RankingStrategy private int mRankingStrategy = RANKING_STRATEGY_NONE;
        @Order private int mOrder = ORDER_DESCENDING;
        @GroupingType private int mGroupingTypeFlags = 0;
        private int mGroupingLimit = 0;
        private JoinSpec mJoinSpec;
        private String mAdvancedRankingExpression = "";
        private boolean mBuilt = false;

        /**
         * Indicates how the query terms should match {@code TermMatchCode} in the index.
         *
         * <p>If this method is not called, the default term match type is {@link
         * SearchSpec#TERM_MATCH_PREFIX}.
         */
        @CanIgnoreReturnValue
        @NonNull
        public Builder setTermMatch(@TermMatch int termMatchType) {
            Preconditions.checkArgumentInRange(
                    termMatchType, TERM_MATCH_EXACT_ONLY, TERM_MATCH_PREFIX, "Term match type");
            resetIfBuilt();
            mTermMatchType = termMatchType;
            return this;
        }

        /**
         * Adds a Schema type filter to {@link SearchSpec} Entry. Only search for documents that
         * have the specified schema types.
         *
         * <p>If unset, the query will search over all schema types.
         */
        @CanIgnoreReturnValue
        @NonNull
        public Builder addFilterSchemas(@NonNull String... schemas) {
            Objects.requireNonNull(schemas);
            resetIfBuilt();
            return addFilterSchemas(Arrays.asList(schemas));
        }

        /**
         * Adds a Schema type filter to {@link SearchSpec} Entry. Only search for documents that
         * have the specified schema types.
         *
         * <p>If unset, the query will search over all schema types.
         */
        @CanIgnoreReturnValue
        @NonNull
        public Builder addFilterSchemas(@NonNull Collection<String> schemas) {
            Objects.requireNonNull(schemas);
            resetIfBuilt();
            mSchemas.addAll(schemas);
            return this;
        }

        /**
         * Adds a namespace filter to {@link SearchSpec} Entry. Only search for documents that have
         * the specified namespaces.
         *
         * <p>If unset, the query will search over all namespaces.
         */
        @CanIgnoreReturnValue
        @NonNull
        public Builder addFilterNamespaces(@NonNull String... namespaces) {
            Objects.requireNonNull(namespaces);
            resetIfBuilt();
            return addFilterNamespaces(Arrays.asList(namespaces));
        }

        /**
         * Adds a namespace filter to {@link SearchSpec} Entry. Only search for documents that have
         * the specified namespaces.
         *
         * <p>If unset, the query will search over all namespaces.
         */
        @CanIgnoreReturnValue
        @NonNull
        public Builder addFilterNamespaces(@NonNull Collection<String> namespaces) {
            Objects.requireNonNull(namespaces);
            resetIfBuilt();
            mNamespaces.addAll(namespaces);
            return this;
        }

        /**
         * Adds a package name filter to {@link SearchSpec} Entry. Only search for documents that
         * were indexed from the specified packages.
         *
         * <p>If unset, the query will search over all packages that the caller has access to. If
         * package names are specified which caller doesn't have access to, then those package names
         * will be ignored.
         */
        @CanIgnoreReturnValue
        @NonNull
        public Builder addFilterPackageNames(@NonNull String... packageNames) {
            Objects.requireNonNull(packageNames);
            resetIfBuilt();
            return addFilterPackageNames(Arrays.asList(packageNames));
        }

        /**
         * Adds a package name filter to {@link SearchSpec} Entry. Only search for documents that
         * were indexed from the specified packages.
         *
         * <p>If unset, the query will search over all packages that the caller has access to. If
         * package names are specified which caller doesn't have access to, then those package names
         * will be ignored.
         */
        @CanIgnoreReturnValue
        @NonNull
        public Builder addFilterPackageNames(@NonNull Collection<String> packageNames) {
            Objects.requireNonNull(packageNames);
            resetIfBuilt();
            mPackageNames.addAll(packageNames);
            return this;
        }

        /**
         * Sets the number of results per page in the returned object.
         *
         * <p>The default number of results per page is 10.
         */
        @CanIgnoreReturnValue
        @NonNull
        public SearchSpec.Builder setResultCountPerPage(
                @IntRange(from = 0, to = MAX_NUM_PER_PAGE) int resultCountPerPage) {
            Preconditions.checkArgumentInRange(
                    resultCountPerPage, 0, MAX_NUM_PER_PAGE, "resultCountPerPage");
            resetIfBuilt();
            mResultCountPerPage = resultCountPerPage;
            return this;
        }

        /** Sets ranking strategy for AppSearch results. */
        @CanIgnoreReturnValue
        @NonNull
        public Builder setRankingStrategy(@RankingStrategy int rankingStrategy) {
            Preconditions.checkArgumentInRange(
                    rankingStrategy,
                    RANKING_STRATEGY_NONE,
                    RANKING_STRATEGY_JOIN_AGGREGATE_SCORE,
                    "Result ranking strategy");
            resetIfBuilt();
            mRankingStrategy = rankingStrategy;
            mAdvancedRankingExpression = "";
            return this;
        }

        /**
         * Enables advanced ranking to score based on {@code advancedRankingExpression}.
         *
         * <p>This method will set RankingStrategy to {@link
         * #RANKING_STRATEGY_ADVANCED_RANKING_EXPRESSION}.
         *
         * <p>The ranking expression is a mathematical expression that will be evaluated to a
         * floating-point number of double type representing the score of each document.
         *
         * <p>Numeric literals, arithmetic operators, mathematical functions, and document-based
         * functions are supported to build expressions.
         *
         * <p>The following are supported arithmetic operators:
         *
         * <ul>
         *   <li>Addition(+)
         *   <li>Subtraction(-)
         *   <li>Multiplication(*)
         *   <li>Floating Point Division(/)
         * </ul>
         *
         * <p>Operator precedences are compliant with the Java Language, and parentheses are
         * supported. For example, "2.2 + (3 - 4) / 2" evaluates to 1.7.
         *
         * <p>The following are supported basic mathematical functions:
         *
         * <ul>
         *   <li>log(x) - the natural log of x
         *   <li>log(x, y) - the log of y with base x
         *   <li>pow(x, y) - x to the power of y
         *   <li>sqrt(x)
         *   <li>abs(x)
         *   <li>sin(x), cos(x), tan(x)
         *   <li>Example: "max(abs(-100), 10) + pow(2, 10)" will be evaluated to 1124
         * </ul>
         *
         * <p>The following variadic mathematical functions are supported, with n > 0. They also
         * accept list value parameters. For example, if V is a value of list type, we can call
         * sum(V) to get the sum of all the values in V. List literals are not supported, so a value
         * of list type can only be constructed as a return value of some particular document-based
         * functions.
         *
         * <ul>
         *   <li>max(v1, v2, ..., vn) or max(V)
         *   <li>min(v1, v2, ..., vn) or min(V)
         *   <li>len(v1, v2, ..., vn) or len(V)
         *   <li>sum(v1, v2, ..., vn) or sum(V)
         *   <li>avg(v1, v2, ..., vn) or avg(V)
         * </ul>
         *
         * <p>Document-based functions must be called via "this", which represents the current
         * document being scored. The following are supported document-based functions:
         *
         * <ul>
         *   <li>this.documentScore()
         *       <p>Get the app-provided document score of the current document. This is the same
         *       score that is returned for {@link #RANKING_STRATEGY_DOCUMENT_SCORE}.
         *   <li>this.creationTimestamp()
         *       <p>Get the creation timestamp of the current document. This is the same score that
         *       is returned for {@link #RANKING_STRATEGY_CREATION_TIMESTAMP}.
         *   <li>this.relevanceScore()
         *       <p>Get the BM25F relevance score of the current document in relation to the query
         *       string. This is the same score that is returned for {@link
         *       #RANKING_STRATEGY_RELEVANCE_SCORE}.
         *   <li>this.usageCount(type) and this.usageLastUsedTimestamp(type)
         *       <p>Get the number of usages or the timestamp of last usage by type for the current
         *       document, where type must be evaluated to an integer from 1 to 2. Type 1 refers to
         *       usages reported by {@link AppSearchSession#reportUsage}, and type 2 refers to
         *       usages reported by {@link GlobalSearchSession#reportSystemUsage}.
         *   <li>this.childrenScores()
         *       <p>Returns a list of children document scores. Currently, a document can only be a
         *       child of another document in the context of joins. If this function is called
         *       without the Join API enabled, a type error will be raised.
         *   <li>this.propertyWeights()
         *       <p>Returns a list of the normalized weights of the matched properties for the
         *       current document being scored. Property weights come from what's specified in
         *       {@link SearchSpec}. After normalizing, each provided weight will be divided by the
         *       maximum weight, so that each of them will be <= 1.
         * </ul>
         *
         * <p>Some errors may occur when using advanced ranking.
         *
         * <p>Syntax Error: the expression violates the syntax of the advanced ranking language.
         * Below are some examples.
         *
         * <ul>
         *   <li>"1 + " - missing operand
         *   <li>"2 * (1 + 2))" - unbalanced parenthesis
         *   <li>"2 ^ 3" - unknown operator
         * </ul>
         *
         * <p>Type Error: the expression fails a static type check. Below are some examples.
         *
         * <ul>
         *   <li>"sin(2, 3)" - wrong number of arguments for the sin function
         *   <li>"this.childrenScores() + 1" - cannot add a list with a number
         *   <li>"this.propertyWeights()" - the final type of the overall expression cannot be a
         *       list, which can be fixed by "max(this.propertyWeights())"
         *   <li>"abs(this.propertyWeights())" - the abs function does not support list type
         *       arguments
         *   <li>"print(2)" - unknown function
         * </ul>
         *
         * <p>Evaluation Error: an error occurred while evaluating the value of the expression.
         * Below are some examples.
         *
         * <ul>
         *   <li>"1 / 0", "log(0)", "1 + sqrt(-1)" - getting a non-finite value in the middle of
         *       evaluation
         *   <li>"this.usageCount(1 + 0.5)" - expect the argument to be an integer. Note that this
         *       is not a type error and "this.usageCount(1.5 + 1/2)" can succeed without any issues
         *   <li>"this.documentScore()" - in case of an IO error, this will be an evaluation error
         * </ul>
         *
         * <p>Syntax errors and type errors will fail the entire search and will cause {@link
         * SearchResults#getNextPage} to throw an {@link AppSearchException} with the result code of
         * {@link AppSearchResult#RESULT_INVALID_ARGUMENT}.
         *
         * <p>Evaluation errors will result in the offending documents receiving the default score.
         * For {@link #ORDER_DESCENDING}, the default score will be 0, for {@link #ORDER_ASCENDING}
         * the default score will be infinity.
         *
         * @param advancedRankingExpression a non-empty string representing the ranking expression.
         */
        @CanIgnoreReturnValue
        @NonNull
        public Builder setRankingStrategy(@NonNull String advancedRankingExpression) {
            Preconditions.checkStringNotEmpty(advancedRankingExpression);
            resetIfBuilt();
            mRankingStrategy = RANKING_STRATEGY_ADVANCED_RANKING_EXPRESSION;
            mAdvancedRankingExpression = advancedRankingExpression;
            return this;
        }

        /**
         * Indicates the order of returned search results, the default is {@link #ORDER_DESCENDING},
         * meaning that results with higher scores come first.
         *
         * <p>This order field will be ignored if RankingStrategy = {@code RANKING_STRATEGY_NONE}.
         */
        @CanIgnoreReturnValue
        @NonNull
        public Builder setOrder(@Order int order) {
            Preconditions.checkArgumentInRange(
                    order, ORDER_DESCENDING, ORDER_ASCENDING, "Result ranking order");
            resetIfBuilt();
            mOrder = order;
            return this;
        }

        /**
         * Only the first {@code snippetCount} documents based on the ranking strategy will have
         * snippet information provided.
         *
         * <p>The list returned from {@link SearchResult#getMatchInfos} will contain at most this
         * many entries.
         *
         * <p>If set to 0 (default), snippeting is disabled and the list returned from {@link
         * SearchResult#getMatchInfos} will be empty.
         */
        @CanIgnoreReturnValue
        @NonNull
        public SearchSpec.Builder setSnippetCount(
                @IntRange(from = 0, to = MAX_SNIPPET_COUNT) int snippetCount) {
            Preconditions.checkArgumentInRange(snippetCount, 0, MAX_SNIPPET_COUNT, "snippetCount");
            resetIfBuilt();
            mSnippetCount = snippetCount;
            return this;
        }

        /**
         * Sets {@code snippetCountPerProperty}. Only the first {@code snippetCountPerProperty}
         * snippets for each property of each {@link GenericDocument} will contain snippet
         * information.
         *
         * <p>If set to 0, snippeting is disabled and the list returned from {@link
         * SearchResult#getMatchInfos} will be empty.
         *
         * <p>The default behavior is to snippet all matches a property contains, up to the maximum
         * value of 10,000.
         */
        @CanIgnoreReturnValue
        @NonNull
        public SearchSpec.Builder setSnippetCountPerProperty(
                @IntRange(from = 0, to = MAX_SNIPPET_PER_PROPERTY_COUNT)
                        int snippetCountPerProperty) {
            Preconditions.checkArgumentInRange(
                    snippetCountPerProperty,
                    0,
                    MAX_SNIPPET_PER_PROPERTY_COUNT,
                    "snippetCountPerProperty");
            resetIfBuilt();
            mSnippetCountPerProperty = snippetCountPerProperty;
            return this;
        }

        /**
         * Sets {@code maxSnippetSize}, the maximum snippet size. Snippet windows start at {@code
         * maxSnippetSize/2} bytes before the middle of the matching token and end at {@code
         * maxSnippetSize/2} bytes after the middle of the matching token. It respects token
         * boundaries, therefore the returned window may be smaller than requested.
         *
         * <p>Setting {@code maxSnippetSize} to 0 will disable windowing and an empty string will be
         * returned. If matches enabled is also set to false, then snippeting is disabled.
         *
         * <p>Ex. {@code maxSnippetSize} = 16. "foo bar baz bat rat" with a query of "baz" will
         * return a window of "bar baz bat" which is only 11 bytes long.
         */
        @CanIgnoreReturnValue
        @NonNull
        public SearchSpec.Builder setMaxSnippetSize(
                @IntRange(from = 0, to = MAX_SNIPPET_SIZE_LIMIT) int maxSnippetSize) {
            Preconditions.checkArgumentInRange(
                    maxSnippetSize, 0, MAX_SNIPPET_SIZE_LIMIT, "maxSnippetSize");
            resetIfBuilt();
            mMaxSnippetSize = maxSnippetSize;
            return this;
        }

        /**
         * Adds property paths for the specified type to be used for projection. If property paths
         * are added for a type, then only the properties referred to will be retrieved for results
         * of that type. If a property path that is specified isn't present in a result, it will be
         * ignored for that result. Property paths cannot be null.
         *
         * @see #addProjectionPaths
         * @param schema a string corresponding to the schema to add projections to.
         * @param propertyPaths the projections to add.
         */
        @CanIgnoreReturnValue
        @NonNull
        public SearchSpec.Builder addProjection(
                @NonNull String schema, @NonNull Collection<String> propertyPaths) {
            Objects.requireNonNull(schema);
            Objects.requireNonNull(propertyPaths);
            resetIfBuilt();
            ArrayList<String> propertyPathsArrayList = new ArrayList<>(propertyPaths.size());
            for (String propertyPath : propertyPaths) {
                Objects.requireNonNull(propertyPath);
                propertyPathsArrayList.add(propertyPath);
            }
            mProjectionTypePropertyMasks.putStringArrayList(schema, propertyPathsArrayList);
            return this;
        }

        /**
         * Adds property paths for the specified type to be used for projection. If property paths
         * are added for a type, then only the properties referred to will be retrieved for results
         * of that type. If a property path that is specified isn't present in a result, it will be
         * ignored for that result. Property paths cannot be null.
         *
         * <p>If no property paths are added for a particular type, then all properties of results
         * of that type will be retrieved.
         *
         * <p>If property path is added for the {@link SearchSpec#PROJECTION_SCHEMA_TYPE_WILDCARD},
         * then those property paths will apply to all results, excepting any types that have their
         * own, specific property paths set.
         *
         * <p>Suppose the following document is in the index.
         *
         * <pre>{@code
         * Email: Document {
         *   sender: Document {
         *     name: "Mr. Person"
         *     email: "mrperson123@google.com"
         *   }
         *   recipients: [
         *     Document {
         *       name: "John Doe"
         *       email: "johndoe123@google.com"
         *     }
         *     Document {
         *       name: "Jane Doe"
         *       email: "janedoe123@google.com"
         *     }
         *   ]
         *   subject: "IMPORTANT"
         *   body: "Limited time offer!"
         * }
         * }</pre>
         *
         * <p>Then, suppose that a query for "important" is issued with the following projection
         * type property paths:
         *
         * <pre>{@code
         * {schema: "Email", ["subject", "sender.name", "recipients.name"]}
         * }</pre>
         *
         * <p>The above document will be returned as:
         *
         * <pre>{@code
         * Email: Document {
         *   sender: Document {
         *     name: "Mr. Body"
         *   }
         *   recipients: [
         *     Document {
         *       name: "John Doe"
         *     }
         *     Document {
         *       name: "Jane Doe"
         *     }
         *   ]
         *   subject: "IMPORTANT"
         * }
         * }</pre>
         *
         * @param schema a string corresponding to the schema to add projections to.
         * @param propertyPaths the projections to add.
         */
        @CanIgnoreReturnValue
        @NonNull
        public SearchSpec.Builder addProjectionPaths(
                @NonNull String schema, @NonNull Collection<PropertyPath> propertyPaths) {
            Objects.requireNonNull(schema);
            Objects.requireNonNull(propertyPaths);
            ArrayList<String> propertyPathsArrayList = new ArrayList<>(propertyPaths.size());
            for (PropertyPath propertyPath : propertyPaths) {
                propertyPathsArrayList.add(propertyPath.toString());
            }
            return addProjection(schema, propertyPathsArrayList);
        }

        /**
         * Sets the maximum number of results to return for each group, where groups are defined by
         * grouping type.
         *
         * <p>Calling this method will override any previous calls. So calling
         * setResultGrouping(GROUPING_TYPE_PER_PACKAGE, 7) and then calling
         * setResultGrouping(GROUPING_TYPE_PER_PACKAGE, 2) will result in only the latter, a limit
         * of two results per package, being applied. Or calling setResultGrouping
         * (GROUPING_TYPE_PER_PACKAGE, 1) and then calling setResultGrouping
         * (GROUPING_TYPE_PER_PACKAGE | GROUPING_PER_NAMESPACE, 5) will result in five results per
         * package per namespace.
         *
         * @param groupingTypeFlags One or more combination of grouping types.
         * @param limit Number of results to return per {@code groupingTypeFlags}.
         * @throws IllegalArgumentException if groupingTypeFlags is zero.
         */
        // Individual parameters available from getResultGroupingTypeFlags and
        // getResultGroupingLimit
        @CanIgnoreReturnValue
        @SuppressLint("MissingGetterMatchingBuilder")
        @NonNull
        public Builder setResultGrouping(@GroupingType int groupingTypeFlags, int limit) {
            Preconditions.checkState(
                    groupingTypeFlags != 0, "Result grouping type cannot be zero.");
            resetIfBuilt();
            mGroupingTypeFlags = groupingTypeFlags;
            mGroupingLimit = limit;
            return this;
        }

        /**
         * Sets property weights by schema type and property path.
         *
         * <p>Property weights are used to promote and demote query term matches within a {@link
         * GenericDocument} property when applying scoring.
         *
         * <p>Property weights must be positive values (greater than 0). A property's weight is
         * multiplied with that property's scoring contribution. This means weights set between 0.0
         * and 1.0 demote scoring contributions by a term match within the property. Weights set
         * above 1.0 promote scoring contributions by a term match within the property.
         *
         * <p>Properties that exist in the {@link AppSearchSchema}, but do not have a weight
         * explicitly set will be given a default weight of 1.0.
         *
         * <p>Weights set for property paths that do not exist in the {@link AppSearchSchema} will
         * be discarded and not affect scoring.
         *
         * <p><b>NOTE:</b> Property weights only affect scoring for query-dependent scoring
         * strategies, such as {@link #RANKING_STRATEGY_RELEVANCE_SCORE}.
         *
         * @param schemaType the schema type to set property weights for.
         * @param propertyPathWeights a {@link Map} of property paths of the schema type to the
         *     weight to set for that property.
         * @throws IllegalArgumentException if a weight is equal to or less than 0.0.
         */
        @NonNull
        public SearchSpec.Builder setPropertyWeights(
                @NonNull String schemaType, @NonNull Map<String, Double> propertyPathWeights) {
            Objects.requireNonNull(schemaType);
            Objects.requireNonNull(propertyPathWeights);

            Bundle propertyPathBundle = new Bundle();
            for (Map.Entry<String, Double> propertyPathWeightEntry :
                    propertyPathWeights.entrySet()) {
                String propertyPath = Objects.requireNonNull(propertyPathWeightEntry.getKey());
                Double weight = Objects.requireNonNull(propertyPathWeightEntry.getValue());
                if (weight <= 0.0) {
                    throw new IllegalArgumentException(
                            "Cannot set non-positive property weight "
                                    + "value "
                                    + weight
                                    + " for property path: "
                                    + propertyPath);
                }
                propertyPathBundle.putDouble(propertyPath, weight);
            }
            mTypePropertyWeights.putBundle(schemaType, propertyPathBundle);
            return this;
        }

        /**
         * Specifies which documents to join with, and how to join.
         *
         * <p>If the ranking strategy is {@link #RANKING_STRATEGY_JOIN_AGGREGATE_SCORE}, and the
         * JoinSpec is null, {@link #build} will throw an {@link AppSearchException}.
         *
         * @param joinSpec a specification on how to perform the Join operation.
         */
        @NonNull
        public Builder setJoinSpec(@NonNull JoinSpec joinSpec) {
            resetIfBuilt();
            mJoinSpec = Objects.requireNonNull(joinSpec);
            return this;
        }

        /**
         * Sets property weights by schema type and property path.
         *
         * <p>Property weights are used to promote and demote query term matches within a {@link
         * GenericDocument} property when applying scoring.
         *
         * <p>Property weights must be positive values (greater than 0). A property's weight is
         * multiplied with that property's scoring contribution. This means weights set between 0.0
         * and 1.0 demote scoring contributions by a term match within the property. Weights set
         * above 1.0 promote scoring contributions by a term match within the property.
         *
         * <p>Properties that exist in the {@link AppSearchSchema}, but do not have a weight
         * explicitly set will be given a default weight of 1.0.
         *
         * <p>Weights set for property paths that do not exist in the {@link AppSearchSchema} will
         * be discarded and not affect scoring.
         *
         * <p><b>NOTE:</b> Property weights only affect scoring for query-dependent scoring
         * strategies, such as {@link #RANKING_STRATEGY_RELEVANCE_SCORE}.
         *
         * @param schemaType the schema type to set property weights for.
         * @param propertyPathWeights a {@link Map} of property paths of the schema type to the
         *     weight to set for that property.
         * @throws IllegalArgumentException if a weight is equal to or less than 0.0.
         */
        @NonNull
        public SearchSpec.Builder setPropertyWeightPaths(
                @NonNull String schemaType,
                @NonNull Map<PropertyPath, Double> propertyPathWeights) {
            Objects.requireNonNull(propertyPathWeights);

            Map<String, Double> propertyWeights = new ArrayMap<>(propertyPathWeights.size());
            for (Map.Entry<PropertyPath, Double> propertyPathWeightEntry :
                    propertyPathWeights.entrySet()) {
                PropertyPath propertyPath =
                        Objects.requireNonNull(propertyPathWeightEntry.getKey());
                propertyWeights.put(propertyPath.toString(), propertyPathWeightEntry.getValue());
            }
            return setPropertyWeights(schemaType, propertyWeights);
        }

        /**
         * Sets the {@link Features#NUMERIC_SEARCH} feature as enabled/disabled according to the
         * enabled parameter.
         *
         * @param enabled Enables the feature if true, otherwise disables it.
         *     <p>If disabled, disallows use of {@link
         *     AppSearchSchema.LongPropertyConfig#INDEXING_TYPE_RANGE} and all other numeric
         *     querying features.
         */
        @NonNull
        public Builder setNumericSearchEnabled(boolean enabled) {
            modifyEnabledFeature(FeatureConstants.NUMERIC_SEARCH, enabled);
            return this;
        }

        /**
         * Sets the {@link Features#VERBATIM_SEARCH} feature as enabled/disabled according to the
         * enabled parameter.
         *
         * @param enabled Enables the feature if true, otherwise disables it
         *     <p>If disabled, disallows use of {@link
         *     AppSearchSchema.StringPropertyConfig#TOKENIZER_TYPE_VERBATIM} and all other verbatim
         *     search features within the query language that allows clients to search using the
         *     verbatim string operator.
         *     <p>Ex. The verbatim string operator '"foo/bar" OR baz' will ensure that 'foo/bar' is
         *     treated as a single 'verbatim' token.
         */
        @NonNull
        public Builder setVerbatimSearchEnabled(boolean enabled) {
            modifyEnabledFeature(FeatureConstants.VERBATIM_SEARCH, enabled);
            return this;
        }

        /**
         * Sets the {@link Features#LIST_FILTER_QUERY_LANGUAGE} feature as enabled/disabled
         * according to the enabled parameter.
         *
         * @param enabled Enables the feature if true, otherwise disables it.
         *     <p>This feature covers the expansion of the query language to conform to the
         *     definition of the list filters language (https://aip.dev/160). This includes:
         *     <ul>
         *       <li>addition of explicit 'AND' and 'NOT' operators
         *       <li>property restricts are allowed with grouping (ex. "prop:(a OR b)")
         *       <li>addition of custom functions to control matching
         *     </ul>
         *     <p>The newly added custom functions covered by this feature are:
         *     <ul>
         *       <li>createList(String...)
         *       <li>termSearch(String, List<String>)
         *     </ul>
         *     <p>createList takes a variable number of strings and returns a list of strings. It is
         *     for use with termSearch.
         *     <p>termSearch takes a query string that will be parsed according to the supported
         *     query language and an optional list of strings that specify the properties to be
         *     restricted to. This exists as a convenience for multiple property restricts. So, for
         *     example, the query "(subject:foo OR body:foo) (subject:bar OR body:bar)" could be
         *     rewritten as "termSearch(\"foo bar\", createList(\"subject\", \"bar\"))"
         */
        @NonNull
        public Builder setListFilterQueryLanguageEnabled(boolean enabled) {
            modifyEnabledFeature(FeatureConstants.LIST_FILTER_QUERY_LANGUAGE, enabled);
            return this;
        }

        /**
         * Constructs a new {@link SearchSpec} from the contents of this builder.
         *
         * @throws IllegalArgumentException if property weights are provided with a ranking strategy
         *     that isn't RANKING_STRATEGY_RELEVANCE_SCORE.
         * @throws IllegalStateException if the ranking strategy is {@link
         *     #RANKING_STRATEGY_JOIN_AGGREGATE_SCORE} and {@link #setJoinSpec} has never been
         *     called.
         * @throws IllegalStateException if the aggregation scoring strategy has been set in {@link
         *     JoinSpec#getAggregationScoringStrategy()} but the ranking strategy is not {@link
         *     #RANKING_STRATEGY_JOIN_AGGREGATE_SCORE}.
         */
        @NonNull
        public SearchSpec build() {
            Bundle bundle = new Bundle();
            if (mJoinSpec != null) {
                if (mRankingStrategy != RANKING_STRATEGY_JOIN_AGGREGATE_SCORE
                        && mJoinSpec.getAggregationScoringStrategy()
                                != JoinSpec.AGGREGATION_SCORING_OUTER_RESULT_RANKING_SIGNAL) {
                    throw new IllegalStateException(
                            "Aggregate scoring strategy has been set in "
                                    + "the nested JoinSpec, but ranking strategy is not "
                                    + "RANKING_STRATEGY_JOIN_AGGREGATE_SCORE");
                }
                bundle.putBundle(JOIN_SPEC, mJoinSpec.getBundle());
            } else if (mRankingStrategy == RANKING_STRATEGY_JOIN_AGGREGATE_SCORE) {
                throw new IllegalStateException(
                        "Attempting to rank based on joined documents, but "
                                + "no JoinSpec provided");
            }
            bundle.putStringArrayList(SCHEMA_FIELD, mSchemas);
            bundle.putStringArrayList(NAMESPACE_FIELD, mNamespaces);
            bundle.putStringArrayList(PACKAGE_NAME_FIELD, mPackageNames);
            bundle.putStringArrayList(ENABLED_FEATURES_FIELD, new ArrayList<>(mEnabledFeatures));
            bundle.putBundle(PROJECTION_TYPE_PROPERTY_PATHS_FIELD, mProjectionTypePropertyMasks);
            bundle.putInt(NUM_PER_PAGE_FIELD, mResultCountPerPage);
            bundle.putInt(TERM_MATCH_TYPE_FIELD, mTermMatchType);
            bundle.putInt(SNIPPET_COUNT_FIELD, mSnippetCount);
            bundle.putInt(SNIPPET_COUNT_PER_PROPERTY_FIELD, mSnippetCountPerProperty);
            bundle.putInt(MAX_SNIPPET_FIELD, mMaxSnippetSize);
            bundle.putInt(RANKING_STRATEGY_FIELD, mRankingStrategy);
            bundle.putInt(ORDER_FIELD, mOrder);
            bundle.putInt(RESULT_GROUPING_TYPE_FLAGS, mGroupingTypeFlags);
            bundle.putInt(RESULT_GROUPING_LIMIT, mGroupingLimit);
            if (!mTypePropertyWeights.isEmpty()
                    && RANKING_STRATEGY_RELEVANCE_SCORE != mRankingStrategy
                    && RANKING_STRATEGY_ADVANCED_RANKING_EXPRESSION != mRankingStrategy) {
                throw new IllegalArgumentException(
                        "Property weights are only compatible with the"
                            + " RANKING_STRATEGY_RELEVANCE_SCORE and"
                            + " RANKING_STRATEGY_ADVANCED_RANKING_EXPRESSION ranking strategies.");
            }
            bundle.putBundle(TYPE_PROPERTY_WEIGHTS_FIELD, mTypePropertyWeights);
            bundle.putString(ADVANCED_RANKING_EXPRESSION, mAdvancedRankingExpression);
            mBuilt = true;
            return new SearchSpec(bundle);
        }

        private void resetIfBuilt() {
            if (mBuilt) {
                mSchemas = new ArrayList<>(mSchemas);
                mNamespaces = new ArrayList<>(mNamespaces);
                mPackageNames = new ArrayList<>(mPackageNames);
                mProjectionTypePropertyMasks = BundleUtil.deepCopy(mProjectionTypePropertyMasks);
                mTypePropertyWeights = BundleUtil.deepCopy(mTypePropertyWeights);
                mBuilt = false;
            }
        }

        private void modifyEnabledFeature(@NonNull String feature, boolean enabled) {
            resetIfBuilt();
            if (enabled) {
                mEnabledFeatures.add(feature);
            } else {
                mEnabledFeatures.remove(feature);
            }
        }
    }
}
