/*
 * 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.CurrentTimeMillisLong;
import android.annotation.IntRange;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.annotation.SuppressLint;
import android.app.appsearch.PropertyPath.PathSegment;
import android.app.appsearch.annotation.CanIgnoreReturnValue;
import android.app.appsearch.util.BundleUtil;
import android.app.appsearch.util.IndentingStringBuilder;
import android.os.Bundle;
import android.os.Parcelable;
import android.util.Log;

import java.lang.reflect.Array;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.Set;

/**
 * Represents a document unit.
 *
 * <p>Documents contain structured data conforming to their {@link AppSearchSchema} type. Each
 * document is uniquely identified by a namespace and a String ID within that namespace.
 *
 * <p>Documents are constructed by using the {@link GenericDocument.Builder}.
 *
 * @see AppSearchSession#put
 * @see AppSearchSession#getByDocumentId
 * @see AppSearchSession#search
 */
public class GenericDocument {
    private static final String TAG = "AppSearchGenericDocumen";

    /** The maximum number of indexed properties a document can have. */
    private static final int MAX_INDEXED_PROPERTIES = 16;

    /** The default score of document. */
    private static final int DEFAULT_SCORE = 0;

    /** The default time-to-live in millisecond of a document, which is infinity. */
    private static final long DEFAULT_TTL_MILLIS = 0L;

    private static final String PROPERTIES_FIELD = "properties";
    private static final String BYTE_ARRAY_FIELD = "byteArray";
    private static final String SCHEMA_TYPE_FIELD = "schemaType";
    private static final String ID_FIELD = "id";
    private static final String SCORE_FIELD = "score";
    private static final String TTL_MILLIS_FIELD = "ttlMillis";
    private static final String CREATION_TIMESTAMP_MILLIS_FIELD = "creationTimestampMillis";
    private static final String NAMESPACE_FIELD = "namespace";

    /**
     * The maximum number of indexed properties a document can have.
     *
     * <p>Indexed properties are properties which are strings where the {@link
     * AppSearchSchema.StringPropertyConfig#getIndexingType} value is anything other than {@link
     * AppSearchSchema.StringPropertyConfig#INDEXING_TYPE_NONE}.
     */
    public static int getMaxIndexedProperties() {
        return MAX_INDEXED_PROPERTIES;
    }

    /**
     * Contains all {@link GenericDocument} information in a packaged format.
     *
     * <p>Keys are the {@code *_FIELD} constants in this class.
     */
    @NonNull final Bundle mBundle;

    /** Contains all properties in {@link GenericDocument} to support getting properties via name */
    @NonNull private final Bundle mProperties;

    @NonNull private final String mId;
    @NonNull private final String mSchemaType;
    private final long mCreationTimestampMillis;
    @Nullable private Integer mHashCode;

    /**
     * Rebuilds a {@link GenericDocument} from a bundle.
     *
     * @param bundle Packaged {@link GenericDocument} data, such as the result of {@link
     *     #getBundle}.
     * @hide
     */
    @SuppressWarnings("deprecation")
    public GenericDocument(@NonNull Bundle bundle) {
        Objects.requireNonNull(bundle);
        mBundle = bundle;
        mProperties = Objects.requireNonNull(bundle.getParcelable(PROPERTIES_FIELD));
        mId = Objects.requireNonNull(mBundle.getString(ID_FIELD));
        mSchemaType = Objects.requireNonNull(mBundle.getString(SCHEMA_TYPE_FIELD));
        mCreationTimestampMillis =
                mBundle.getLong(CREATION_TIMESTAMP_MILLIS_FIELD, System.currentTimeMillis());
    }

    /**
     * Creates a new {@link GenericDocument} from an existing instance.
     *
     * <p>This method should be only used by constructor of a subclass.
     */
    protected GenericDocument(@NonNull GenericDocument document) {
        this(document.mBundle);
    }

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

    /** Returns the unique identifier of the {@link GenericDocument}. */
    @NonNull
    public String getId() {
        return mId;
    }

    /** Returns the namespace of the {@link GenericDocument}. */
    @NonNull
    public String getNamespace() {
        return mBundle.getString(NAMESPACE_FIELD, /*defaultValue=*/ "");
    }

    /** Returns the {@link AppSearchSchema} type of the {@link GenericDocument}. */
    @NonNull
    public String getSchemaType() {
        return mSchemaType;
    }

    /**
     * Returns the creation timestamp of the {@link GenericDocument}, in milliseconds.
     *
     * <p>The value is in the {@link System#currentTimeMillis} time base.
     */
    @CurrentTimeMillisLong
    public long getCreationTimestampMillis() {
        return mCreationTimestampMillis;
    }

    /**
     * Returns the TTL (time-to-live) of the {@link GenericDocument}, in milliseconds.
     *
     * <p>The TTL is measured against {@link #getCreationTimestampMillis}. At the timestamp of
     * {@code creationTimestampMillis + ttlMillis}, measured in the {@link System#currentTimeMillis}
     * time base, the document will be auto-deleted.
     *
     * <p>The default value is 0, which means the document is permanent and won't be auto-deleted
     * until the app is uninstalled or {@link AppSearchSession#remove} is called.
     */
    public long getTtlMillis() {
        return mBundle.getLong(TTL_MILLIS_FIELD, DEFAULT_TTL_MILLIS);
    }

    /**
     * Returns the score of the {@link GenericDocument}.
     *
     * <p>The score is a query-independent measure of the document's quality, relative to other
     * {@link GenericDocument} objects of the same {@link AppSearchSchema} type.
     *
     * <p>Results may be sorted by score using {@link SearchSpec.Builder#setRankingStrategy}.
     * Documents with higher scores are considered better than documents with lower scores.
     *
     * <p>Any non-negative integer can be used a score.
     */
    public int getScore() {
        return mBundle.getInt(SCORE_FIELD, DEFAULT_SCORE);
    }

    /** Returns the names of all properties defined in this document. */
    @NonNull
    public Set<String> getPropertyNames() {
        return Collections.unmodifiableSet(mProperties.keySet());
    }

    /**
     * Retrieves the property value with the given path as {@link Object}.
     *
     * <p>A path can be a simple property name, such as those returned by {@link #getPropertyNames}.
     * It may also be a dot-delimited path through the nested document hierarchy, with nested {@link
     * GenericDocument} properties accessed via {@code '.'} and repeated properties optionally
     * indexed into via {@code [n]}.
     *
     * <p>For example, given the following {@link GenericDocument}:
     *
     * <pre>
     *     (Message) {
     *         from: "sender@example.com"
     *         to: [{
     *             name: "Albert Einstein"
     *             email: "einstein@example.com"
     *           }, {
     *             name: "Marie Curie"
     *             email: "curie@example.com"
     *           }]
     *         tags: ["important", "inbox"]
     *         subject: "Hello"
     *     }
     * </pre>
     *
     * <p>Here are some example paths and their results:
     *
     * <ul>
     *   <li>{@code "from"} returns {@code "sender@example.com"} as a {@link String} array with one
     *       element
     *   <li>{@code "to"} returns the two nested documents containing contact information as a
     *       {@link GenericDocument} array with two elements
     *   <li>{@code "to[1]"} returns the second nested document containing Marie Curie's contact
     *       information as a {@link GenericDocument} array with one element
     *   <li>{@code "to[1].email"} returns {@code "curie@example.com"}
     *   <li>{@code "to[100].email"} returns {@code null} as this particular document does not have
     *       that many elements in its {@code "to"} array.
     *   <li>{@code "to.email"} aggregates emails across all nested documents that have them,
     *       returning {@code ["einstein@example.com", "curie@example.com"]} as a {@link String}
     *       array with two elements.
     * </ul>
     *
     * <p>If you know the expected type of the property you are retrieving, it is recommended to use
     * one of the typed versions of this method instead, such as {@link #getPropertyString} or
     * {@link #getPropertyStringArray}.
     *
     * <p>If the property was assigned as an empty array using one of the {@code
     * Builder#setProperty} functions, this method will return an empty array. If no such property
     * exists at all, this method returns {@code null}.
     *
     * <p>Note: If the property is an empty {@link GenericDocument}[] or {@code byte[][]}, this
     * method will return a {@code null} value in versions of Android prior to {@link
     * android.os.Build.VERSION_CODES#TIRAMISU Android T}. Starting in Android T it will return an
     * empty array if the property has been set as an empty array, matching the behavior of other
     * property types.
     *
     * @param path The path to look for.
     * @return The entry with the given path as an object or {@code null} if there is no such path.
     *     The returned object will be one of the following types: {@code String[]}, {@code long[]},
     *     {@code double[]}, {@code boolean[]}, {@code byte[][]}, {@code GenericDocument[]}.
     */
    @Nullable
    public Object getProperty(@NonNull String path) {
        Objects.requireNonNull(path);
        Object rawValue =
                getRawPropertyFromRawDocument(new PropertyPath(path), /*pathIndex=*/ 0, mBundle);

        // Unpack the raw value into the types the user expects, if required.
        if (rawValue instanceof Bundle) {
            // getRawPropertyFromRawDocument may return a document as a bare Bundle as a performance
            // optimization for lookups.
            GenericDocument document = new GenericDocument((Bundle) rawValue);
            return new GenericDocument[] {document};
        }

        if (rawValue instanceof List) {
            // byte[][] fields are packed into List<Bundle> where each Bundle contains just a single
            // entry: BYTE_ARRAY_FIELD -> byte[].
            @SuppressWarnings("unchecked")
            List<Bundle> bundles = (List<Bundle>) rawValue;
            byte[][] bytes = new byte[bundles.size()][];
            for (int i = 0; i < bundles.size(); i++) {
                Bundle bundle = bundles.get(i);
                if (bundle == null) {
                    Log.e(TAG, "The inner bundle is null at " + i + ", for path: " + path);
                    continue;
                }
                byte[] innerBytes = bundle.getByteArray(BYTE_ARRAY_FIELD);
                if (innerBytes == null) {
                    Log.e(TAG, "The bundle at " + i + " contains a null byte[].");
                    continue;
                }
                bytes[i] = innerBytes;
            }
            return bytes;
        }

        if (rawValue instanceof Parcelable[]) {
            // The underlying Bundle of nested GenericDocuments is packed into a Parcelable array.
            // We must unpack it into GenericDocument instances.
            Parcelable[] bundles = (Parcelable[]) rawValue;
            GenericDocument[] documents = new GenericDocument[bundles.length];
            for (int i = 0; i < bundles.length; i++) {
                if (bundles[i] == null) {
                    Log.e(TAG, "The inner bundle is null at " + i + ", for path: " + path);
                    continue;
                }
                if (!(bundles[i] instanceof Bundle)) {
                    Log.e(
                            TAG,
                            "The inner element at "
                                    + i
                                    + " is a "
                                    + bundles[i].getClass()
                                    + ", not a Bundle for path: "
                                    + path);
                    continue;
                }
                documents[i] = new GenericDocument((Bundle) bundles[i]);
            }
            return documents;
        }

        // Otherwise the raw property is the same as the final property and needs no transformation.
        return rawValue;
    }

    /**
     * Looks up a property path within the given document bundle.
     *
     * <p>The return value may be any of GenericDocument's internal repeated storage types
     * (String[], long[], double[], boolean[], ArrayList&lt;Bundle&gt;, Parcelable[]).
     *
     * <p>Usually, this method takes a path and loops over it to get a property from the bundle. But
     * in the case where we collect documents across repeated nested documents, we need to recurse
     * back into this method, and so we also keep track of the index into the path.
     *
     * @param path the PropertyPath object representing the path
     * @param pathIndex the index into the path we start at
     * @param documentBundle the bundle that contains the path we are looking up
     * @return the raw property
     */
    @Nullable
    @SuppressWarnings("deprecation")
    private static Object getRawPropertyFromRawDocument(
            @NonNull PropertyPath path, int pathIndex, @NonNull Bundle documentBundle) {
        Objects.requireNonNull(path);
        Objects.requireNonNull(documentBundle);
        Bundle properties = Objects.requireNonNull(documentBundle.getBundle(PROPERTIES_FIELD));

        for (int i = pathIndex; i < path.size(); i++) {
            PathSegment segment = path.get(i);

            Object currentElementValue = properties.get(segment.getPropertyName());

            if (currentElementValue == null) {
                return null;
            }

            // If the current PathSegment has an index, we now need to update currentElementValue to
            // contain the value of the indexed property. For example, for a path segment like
            // "recipients[0]", currentElementValue now contains the value of "recipients" while we
            // need the value of "recipients[0]".
            int index = segment.getPropertyIndex();
            if (index != PathSegment.NON_REPEATED_CARDINALITY) {
                // Extract the right array element
                Object extractedValue = null;
                if (currentElementValue instanceof String[]) {
                    String[] stringValues = (String[]) currentElementValue;
                    if (index < stringValues.length) {
                        extractedValue = Arrays.copyOfRange(stringValues, index, index + 1);
                    }
                } else if (currentElementValue instanceof long[]) {
                    long[] longValues = (long[]) currentElementValue;
                    if (index < longValues.length) {
                        extractedValue = Arrays.copyOfRange(longValues, index, index + 1);
                    }
                } else if (currentElementValue instanceof double[]) {
                    double[] doubleValues = (double[]) currentElementValue;
                    if (index < doubleValues.length) {
                        extractedValue = Arrays.copyOfRange(doubleValues, index, index + 1);
                    }
                } else if (currentElementValue instanceof boolean[]) {
                    boolean[] booleanValues = (boolean[]) currentElementValue;
                    if (index < booleanValues.length) {
                        extractedValue = Arrays.copyOfRange(booleanValues, index, index + 1);
                    }
                } else if (currentElementValue instanceof List) {
                    @SuppressWarnings("unchecked")
                    List<Bundle> bundles = (List<Bundle>) currentElementValue;
                    if (index < bundles.size()) {
                        extractedValue = bundles.subList(index, index + 1);
                    }
                } else if (currentElementValue instanceof Parcelable[]) {
                    // Special optimization: to avoid creating new singleton arrays for traversing
                    // paths we return the bare document Bundle in this particular case.
                    Parcelable[] bundles = (Parcelable[]) currentElementValue;
                    if (index < bundles.length) {
                        extractedValue = bundles[index];
                    }
                } else {
                    throw new IllegalStateException(
                            "Unsupported value type: " + currentElementValue);
                }
                currentElementValue = extractedValue;
            }

            // at the end of the path, either something like "...foo" or "...foo[1]"
            if (currentElementValue == null || i == path.size() - 1) {
                return currentElementValue;
            }

            // currentElementValue is now a Bundle or Parcelable[], we can continue down the path
            if (currentElementValue instanceof Bundle) {
                properties = ((Bundle) currentElementValue).getBundle(PROPERTIES_FIELD);
            } else if (currentElementValue instanceof Parcelable[]) {
                Parcelable[] parcelables = (Parcelable[]) currentElementValue;
                if (parcelables.length == 1) {
                    properties = ((Bundle) parcelables[0]).getBundle(PROPERTIES_FIELD);
                    continue;
                }

                // Slowest path: we're collecting values across repeated nested docs. (Example:
                // given a path like recipient.name, where recipient is a repeated field, we return
                // a string array where each recipient's name is an array element).
                //
                // Performance note: Suppose that we have a property path "a.b.c" where the "a"
                // property has N document values and each containing a "b" property with M document
                // values and each of those containing a "c" property with an int array.
                //
                // We'll allocate a new ArrayList for each of the "b" properties, add the M int
                // arrays from the "c" properties to it and then we'll allocate an int array in
                // flattenAccumulator before returning that (1 + M allocation per "b" property).
                //
                // When we're on the "a" properties, we'll allocate an ArrayList and add the N
                // flattened int arrays returned from the "b" properties to the list. Then we'll
                // allocate an int array in flattenAccumulator (1 + N ("b" allocs) allocations per
                // "a"). // So this implementation could incur 1 + N + NM allocs.
                //
                // However, we expect the vast majority of getProperty calls to be either for direct
                // property names (not paths) or else property paths returned from snippetting,
                // which always refer to exactly one property value and don't aggregate across
                // repeated values. The implementation is optimized for these two cases, requiring
                // no additional allocations. So we've decided that the above performance
                // characteristics are OK for the less used path.
                List<Object> accumulator = new ArrayList<>(parcelables.length);
                for (Parcelable parcelable : parcelables) {
                    // recurse as we need to branch
                    Object value =
                            getRawPropertyFromRawDocument(
                                    path, /*pathIndex=*/ i + 1, (Bundle) parcelable);
                    if (value != null) {
                        accumulator.add(value);
                    }
                }
                // Break the path traversing loop
                return flattenAccumulator(accumulator);
            } else {
                Log.e(TAG, "Failed to apply path to document; no nested value found: " + path);
                return null;
            }
        }
        // Only way to get here is with an empty path list
        return null;
    }

    /**
     * Combines accumulated repeated properties from multiple documents into a single array.
     *
     * @param accumulator List containing objects of the following types: {@code String[]}, {@code
     *     long[]}, {@code double[]}, {@code boolean[]}, {@code List<Bundle>}, or {@code
     *     Parcelable[]}.
     * @return The result of concatenating each individual list element into a larger array/list of
     *     the same type.
     */
    @Nullable
    private static Object flattenAccumulator(@NonNull List<Object> accumulator) {
        if (accumulator.isEmpty()) {
            return null;
        }
        Object first = accumulator.get(0);
        if (first instanceof String[]) {
            int length = 0;
            for (int i = 0; i < accumulator.size(); i++) {
                length += ((String[]) accumulator.get(i)).length;
            }
            String[] result = new String[length];
            int total = 0;
            for (int i = 0; i < accumulator.size(); i++) {
                String[] castValue = (String[]) accumulator.get(i);
                System.arraycopy(castValue, 0, result, total, castValue.length);
                total += castValue.length;
            }
            return result;
        }
        if (first instanceof long[]) {
            int length = 0;
            for (int i = 0; i < accumulator.size(); i++) {
                length += ((long[]) accumulator.get(i)).length;
            }
            long[] result = new long[length];
            int total = 0;
            for (int i = 0; i < accumulator.size(); i++) {
                long[] castValue = (long[]) accumulator.get(i);
                System.arraycopy(castValue, 0, result, total, castValue.length);
                total += castValue.length;
            }
            return result;
        }
        if (first instanceof double[]) {
            int length = 0;
            for (int i = 0; i < accumulator.size(); i++) {
                length += ((double[]) accumulator.get(i)).length;
            }
            double[] result = new double[length];
            int total = 0;
            for (int i = 0; i < accumulator.size(); i++) {
                double[] castValue = (double[]) accumulator.get(i);
                System.arraycopy(castValue, 0, result, total, castValue.length);
                total += castValue.length;
            }
            return result;
        }
        if (first instanceof boolean[]) {
            int length = 0;
            for (int i = 0; i < accumulator.size(); i++) {
                length += ((boolean[]) accumulator.get(i)).length;
            }
            boolean[] result = new boolean[length];
            int total = 0;
            for (int i = 0; i < accumulator.size(); i++) {
                boolean[] castValue = (boolean[]) accumulator.get(i);
                System.arraycopy(castValue, 0, result, total, castValue.length);
                total += castValue.length;
            }
            return result;
        }
        if (first instanceof List) {
            int length = 0;
            for (int i = 0; i < accumulator.size(); i++) {
                length += ((List<?>) accumulator.get(i)).size();
            }
            List<Bundle> result = new ArrayList<>(length);
            for (int i = 0; i < accumulator.size(); i++) {
                @SuppressWarnings("unchecked")
                List<Bundle> castValue = (List<Bundle>) accumulator.get(i);
                result.addAll(castValue);
            }
            return result;
        }
        if (first instanceof Parcelable[]) {
            int length = 0;
            for (int i = 0; i < accumulator.size(); i++) {
                length += ((Parcelable[]) accumulator.get(i)).length;
            }
            Parcelable[] result = new Parcelable[length];
            int total = 0;
            for (int i = 0; i < accumulator.size(); i++) {
                Parcelable[] castValue = (Parcelable[]) accumulator.get(i);
                System.arraycopy(castValue, 0, result, total, castValue.length);
                total += castValue.length;
            }
            return result;
        }
        throw new IllegalStateException("Unexpected property type: " + first);
    }

    /**
     * Retrieves a {@link String} property by path.
     *
     * <p>See {@link #getProperty} for a detailed description of the path syntax.
     *
     * @param path The path to look for.
     * @return The first {@link String} associated with the given path or {@code null} if there is
     *     no such value or the value is of a different type.
     */
    @Nullable
    public String getPropertyString(@NonNull String path) {
        Objects.requireNonNull(path);
        String[] propertyArray = getPropertyStringArray(path);
        if (propertyArray == null || propertyArray.length == 0) {
            return null;
        }
        warnIfSinglePropertyTooLong("String", path, propertyArray.length);
        return propertyArray[0];
    }

    /**
     * Retrieves a {@code long} property by path.
     *
     * <p>See {@link #getProperty} for a detailed description of the path syntax.
     *
     * @param path The path to look for.
     * @return The first {@code long} associated with the given path or default value {@code 0} if
     *     there is no such value or the value is of a different type.
     */
    public long getPropertyLong(@NonNull String path) {
        Objects.requireNonNull(path);
        long[] propertyArray = getPropertyLongArray(path);
        if (propertyArray == null || propertyArray.length == 0) {
            return 0;
        }
        warnIfSinglePropertyTooLong("Long", path, propertyArray.length);
        return propertyArray[0];
    }

    /**
     * Retrieves a {@code double} property by path.
     *
     * <p>See {@link #getProperty} for a detailed description of the path syntax.
     *
     * @param path The path to look for.
     * @return The first {@code double} associated with the given path or default value {@code 0.0}
     *     if there is no such value or the value is of a different type.
     */
    public double getPropertyDouble(@NonNull String path) {
        Objects.requireNonNull(path);
        double[] propertyArray = getPropertyDoubleArray(path);
        if (propertyArray == null || propertyArray.length == 0) {
            return 0.0;
        }
        warnIfSinglePropertyTooLong("Double", path, propertyArray.length);
        return propertyArray[0];
    }

    /**
     * Retrieves a {@code boolean} property by path.
     *
     * <p>See {@link #getProperty} for a detailed description of the path syntax.
     *
     * @param path The path to look for.
     * @return The first {@code boolean} associated with the given path or default value {@code
     *     false} if there is no such value or the value is of a different type.
     */
    public boolean getPropertyBoolean(@NonNull String path) {
        Objects.requireNonNull(path);
        boolean[] propertyArray = getPropertyBooleanArray(path);
        if (propertyArray == null || propertyArray.length == 0) {
            return false;
        }
        warnIfSinglePropertyTooLong("Boolean", path, propertyArray.length);
        return propertyArray[0];
    }

    /**
     * Retrieves a {@code byte[]} property by path.
     *
     * <p>See {@link #getProperty} for a detailed description of the path syntax.
     *
     * @param path The path to look for.
     * @return The first {@code byte[]} associated with the given path or {@code null} if there is
     *     no such value or the value is of a different type.
     */
    @Nullable
    public byte[] getPropertyBytes(@NonNull String path) {
        Objects.requireNonNull(path);
        byte[][] propertyArray = getPropertyBytesArray(path);
        if (propertyArray == null || propertyArray.length == 0) {
            return null;
        }
        warnIfSinglePropertyTooLong("ByteArray", path, propertyArray.length);
        return propertyArray[0];
    }

    /**
     * Retrieves a {@link GenericDocument} property by path.
     *
     * <p>See {@link #getProperty} for a detailed description of the path syntax.
     *
     * @param path The path to look for.
     * @return The first {@link GenericDocument} associated with the given path or {@code null} if
     *     there is no such value or the value is of a different type.
     */
    @Nullable
    public GenericDocument getPropertyDocument(@NonNull String path) {
        Objects.requireNonNull(path);
        GenericDocument[] propertyArray = getPropertyDocumentArray(path);
        if (propertyArray == null || propertyArray.length == 0) {
            return null;
        }
        warnIfSinglePropertyTooLong("Document", path, propertyArray.length);
        return propertyArray[0];
    }

    /** Prints a warning to logcat if the given propertyLength is greater than 1. */
    private static void warnIfSinglePropertyTooLong(
            @NonNull String propertyType, @NonNull String path, int propertyLength) {
        if (propertyLength > 1) {
            Log.w(
                    TAG,
                    "The value for \""
                            + path
                            + "\" contains "
                            + propertyLength
                            + " elements. Only the first one will be returned from "
                            + "getProperty"
                            + propertyType
                            + "(). Try getProperty"
                            + propertyType
                            + "Array().");
        }
    }

    /**
     * Retrieves a repeated {@code String} property by path.
     *
     * <p>See {@link #getProperty} for a detailed description of the path syntax.
     *
     * <p>If the property has not been set via {@link Builder#setPropertyString}, this method
     * returns {@code null}.
     *
     * <p>If it has been set via {@link Builder#setPropertyString} to an empty {@code String[]},
     * this method returns an empty {@code String[]}.
     *
     * @param path The path to look for.
     * @return The {@code String[]} associated with the given path, or {@code null} if no value is
     *     set or the value is of a different type.
     */
    @Nullable
    public String[] getPropertyStringArray(@NonNull String path) {
        Objects.requireNonNull(path);
        Object value = getProperty(path);
        return safeCastProperty(path, value, String[].class);
    }

    /**
     * Retrieves a repeated {@code long[]} property by path.
     *
     * <p>See {@link #getProperty} for a detailed description of the path syntax.
     *
     * <p>If the property has not been set via {@link Builder#setPropertyLong}, this method returns
     * {@code null}.
     *
     * <p>If it has been set via {@link Builder#setPropertyLong} to an empty {@code long[]}, this
     * method returns an empty {@code long[]}.
     *
     * @param path The path to look for.
     * @return The {@code long[]} associated with the given path, or {@code null} if no value is set
     *     or the value is of a different type.
     */
    @Nullable
    public long[] getPropertyLongArray(@NonNull String path) {
        Objects.requireNonNull(path);
        Object value = getProperty(path);
        return safeCastProperty(path, value, long[].class);
    }

    /**
     * Retrieves a repeated {@code double} property by path.
     *
     * <p>See {@link #getProperty} for a detailed description of the path syntax.
     *
     * <p>If the property has not been set via {@link Builder#setPropertyDouble}, this method
     * returns {@code null}.
     *
     * <p>If it has been set via {@link Builder#setPropertyDouble} to an empty {@code double[]},
     * this method returns an empty {@code double[]}.
     *
     * @param path The path to look for.
     * @return The {@code double[]} associated with the given path, or {@code null} if no value is
     *     set or the value is of a different type.
     */
    @Nullable
    public double[] getPropertyDoubleArray(@NonNull String path) {
        Objects.requireNonNull(path);
        Object value = getProperty(path);
        return safeCastProperty(path, value, double[].class);
    }

    /**
     * Retrieves a repeated {@code boolean} property by path.
     *
     * <p>See {@link #getProperty} for a detailed description of the path syntax.
     *
     * <p>If the property has not been set via {@link Builder#setPropertyBoolean}, this method
     * returns {@code null}.
     *
     * <p>If it has been set via {@link Builder#setPropertyBoolean} to an empty {@code boolean[]},
     * this method returns an empty {@code boolean[]}.
     *
     * @param path The path to look for.
     * @return The {@code boolean[]} associated with the given path, or {@code null} if no value is
     *     set or the value is of a different type.
     */
    @Nullable
    public boolean[] getPropertyBooleanArray(@NonNull String path) {
        Objects.requireNonNull(path);
        Object value = getProperty(path);
        return safeCastProperty(path, value, boolean[].class);
    }

    /**
     * Retrieves a {@code byte[][]} property by path.
     *
     * <p>See {@link #getProperty} for a detailed description of the path syntax.
     *
     * <p>If the property has not been set via {@link Builder#setPropertyBytes}, this method returns
     * {@code null}.
     *
     * <p>If it has been set via {@link Builder#setPropertyBytes} to an empty {@code byte[][]}, this
     * method returns an empty {@code byte[][]} starting in {@link
     * android.os.Build.VERSION_CODES#TIRAMISU Android T} and {@code null} in earlier versions of
     * Android.
     *
     * @param path The path to look for.
     * @return The {@code byte[][]} associated with the given path, or {@code null} if no value is
     *     set or the value is of a different type.
     */
    @SuppressLint("ArrayReturn")
    @Nullable
    public byte[][] getPropertyBytesArray(@NonNull String path) {
        Objects.requireNonNull(path);
        Object value = getProperty(path);
        return safeCastProperty(path, value, byte[][].class);
    }

    /**
     * Retrieves a repeated {@link GenericDocument} property by path.
     *
     * <p>See {@link #getProperty} for a detailed description of the path syntax.
     *
     * <p>If the property has not been set via {@link Builder#setPropertyDocument}, this method
     * returns {@code null}.
     *
     * <p>If it has been set via {@link Builder#setPropertyDocument} to an empty {@code
     * GenericDocument[]}, this method returns an empty {@code GenericDocument[]} starting in {@link
     * android.os.Build.VERSION_CODES#TIRAMISU Android T} and {@code null} in earlier versions of
     * Android.
     *
     * @param path The path to look for.
     * @return The {@link GenericDocument}[] associated with the given path, or {@code null} if no
     *     value is set or the value is of a different type.
     */
    @SuppressLint("ArrayReturn")
    @Nullable
    public GenericDocument[] getPropertyDocumentArray(@NonNull String path) {
        Objects.requireNonNull(path);
        Object value = getProperty(path);
        return safeCastProperty(path, value, GenericDocument[].class);
    }

    /**
     * Casts a repeated property to the provided type, logging an error and returning {@code null}
     * if the cast fails.
     *
     * @param path Path to the property within the document. Used for logging.
     * @param value Value of the property
     * @param tClass Class to cast the value into
     */
    @Nullable
    private static <T> T safeCastProperty(
            @NonNull String path, @Nullable Object value, @NonNull Class<T> tClass) {
        if (value == null) {
            return null;
        }
        try {
            return tClass.cast(value);
        } catch (ClassCastException e) {
            Log.w(TAG, "Error casting to requested type for path \"" + path + "\"", e);
            return null;
        }
    }

    /**
     * Copies the contents of this {@link GenericDocument} into a new {@link
     * GenericDocument.Builder}.
     *
     * <p>The returned builder is a deep copy whose data is separate from this document.
     *
     * @hide
     */
    // TODO(b/171882200): Expose this API in Android T
    @NonNull
    public GenericDocument.Builder<GenericDocument.Builder<?>> toBuilder() {
        Bundle clonedBundle = BundleUtil.deepCopy(mBundle);
        return new GenericDocument.Builder<>(clonedBundle);
    }

    @Override
    public boolean equals(@Nullable Object other) {
        if (this == other) {
            return true;
        }
        if (!(other instanceof GenericDocument)) {
            return false;
        }
        GenericDocument otherDocument = (GenericDocument) other;
        return BundleUtil.deepEquals(this.mBundle, otherDocument.mBundle);
    }

    @Override
    public int hashCode() {
        if (mHashCode == null) {
            mHashCode = BundleUtil.deepHashCode(mBundle);
        }
        return mHashCode;
    }

    @Override
    @NonNull
    public String toString() {
        IndentingStringBuilder stringBuilder = new IndentingStringBuilder();
        appendGenericDocumentString(stringBuilder);
        return stringBuilder.toString();
    }

    /**
     * Appends a debug string for the {@link GenericDocument} instance to the given string builder.
     *
     * @param builder the builder to append to.
     */
    void appendGenericDocumentString(@NonNull IndentingStringBuilder builder) {
        Objects.requireNonNull(builder);

        builder.append("{\n");
        builder.increaseIndentLevel();

        builder.append("namespace: \"").append(getNamespace()).append("\",\n");
        builder.append("id: \"").append(getId()).append("\",\n");
        builder.append("score: ").append(getScore()).append(",\n");
        builder.append("schemaType: \"").append(getSchemaType()).append("\",\n");
        builder.append("creationTimestampMillis: ")
                .append(getCreationTimestampMillis())
                .append(",\n");
        builder.append("timeToLiveMillis: ").append(getTtlMillis()).append(",\n");

        builder.append("properties: {\n");

        String[] sortedProperties = getPropertyNames().toArray(new String[0]);
        Arrays.sort(sortedProperties);

        for (int i = 0; i < sortedProperties.length; i++) {
            Object property = Objects.requireNonNull(getProperty(sortedProperties[i]));
            builder.increaseIndentLevel();
            appendPropertyString(sortedProperties[i], property, builder);
            if (i != sortedProperties.length - 1) {
                builder.append(",\n");
            }
            builder.decreaseIndentLevel();
        }

        builder.append("\n");
        builder.append("}");

        builder.decreaseIndentLevel();
        builder.append("\n");
        builder.append("}");
    }

    /**
     * Appends a debug string for the given document property to the given string builder.
     *
     * @param propertyName name of property to create string for.
     * @param property property object to create string for.
     * @param builder the builder to append to.
     */
    private void appendPropertyString(
            @NonNull String propertyName,
            @NonNull Object property,
            @NonNull IndentingStringBuilder builder) {
        Objects.requireNonNull(propertyName);
        Objects.requireNonNull(property);
        Objects.requireNonNull(builder);

        builder.append("\"").append(propertyName).append("\": [");
        if (property instanceof GenericDocument[]) {
            GenericDocument[] documentValues = (GenericDocument[]) property;
            for (int i = 0; i < documentValues.length; ++i) {
                builder.append("\n");
                builder.increaseIndentLevel();
                documentValues[i].appendGenericDocumentString(builder);
                if (i != documentValues.length - 1) {
                    builder.append(",");
                }
                builder.append("\n");
                builder.decreaseIndentLevel();
            }
            builder.append("]");
        } else {
            int propertyArrLength = Array.getLength(property);
            for (int i = 0; i < propertyArrLength; i++) {
                Object propertyElement = Array.get(property, i);
                if (propertyElement instanceof String) {
                    builder.append("\"").append((String) propertyElement).append("\"");
                } else if (propertyElement instanceof byte[]) {
                    builder.append(Arrays.toString((byte[]) propertyElement));
                } else {
                    builder.append(propertyElement.toString());
                }
                if (i != propertyArrLength - 1) {
                    builder.append(", ");
                } else {
                    builder.append("]");
                }
            }
        }
    }

    /**
     * The builder class for {@link GenericDocument}.
     *
     * @param <BuilderType> Type of subclass who extends this.
     */
    // This builder is specifically designed to be extended by classes deriving from
    // GenericDocument.
    @SuppressLint("StaticFinalBuilder")
    public static class Builder<BuilderType extends Builder> {
        private Bundle mBundle;
        private Bundle mProperties;
        private final BuilderType mBuilderTypeInstance;
        private boolean mBuilt = false;

        /**
         * Creates a new {@link GenericDocument.Builder}.
         *
         * <p>Document IDs are unique within a namespace.
         *
         * <p>The number of namespaces per app should be kept small for efficiency reasons.
         *
         * @param namespace the namespace to set for the {@link GenericDocument}.
         * @param id the unique identifier for the {@link GenericDocument} in its namespace.
         * @param schemaType the {@link AppSearchSchema} type of the {@link GenericDocument}. The
         *     provided {@code schemaType} must be defined using {@link AppSearchSession#setSchema}
         *     prior to inserting a document of this {@code schemaType} into the AppSearch index
         *     using {@link AppSearchSession#put}. Otherwise, the document will be rejected by
         *     {@link AppSearchSession#put} with result code {@link
         *     AppSearchResult#RESULT_NOT_FOUND}.
         */
        @SuppressWarnings("unchecked")
        public Builder(@NonNull String namespace, @NonNull String id, @NonNull String schemaType) {
            Objects.requireNonNull(namespace);
            Objects.requireNonNull(id);
            Objects.requireNonNull(schemaType);

            mBundle = new Bundle();
            mBuilderTypeInstance = (BuilderType) this;
            mBundle.putString(GenericDocument.NAMESPACE_FIELD, namespace);
            mBundle.putString(GenericDocument.ID_FIELD, id);
            mBundle.putString(GenericDocument.SCHEMA_TYPE_FIELD, schemaType);
            mBundle.putLong(GenericDocument.TTL_MILLIS_FIELD, DEFAULT_TTL_MILLIS);
            mBundle.putInt(GenericDocument.SCORE_FIELD, DEFAULT_SCORE);

            mProperties = new Bundle();
            mBundle.putBundle(PROPERTIES_FIELD, mProperties);
        }

        /**
         * Creates a new {@link GenericDocument.Builder} from the given Bundle.
         *
         * <p>The bundle is NOT copied.
         */
        @SuppressWarnings("unchecked")
        Builder(@NonNull Bundle bundle) {
            mBundle = Objects.requireNonNull(bundle);
            // mProperties is NonNull and initialized to empty Bundle() in builder.
            mProperties = Objects.requireNonNull(mBundle.getBundle(PROPERTIES_FIELD));
            mBuilderTypeInstance = (BuilderType) this;
        }

        /**
         * Sets the app-defined namespace this document resides in, changing the value provided in
         * the constructor. No special values are reserved or understood by the infrastructure.
         *
         * <p>Document IDs are unique within a namespace.
         *
         * <p>The number of namespaces per app should be kept small for efficiency reasons.
         *
         * @hide
         */
        @CanIgnoreReturnValue
        @NonNull
        public BuilderType setNamespace(@NonNull String namespace) {
            Objects.requireNonNull(namespace);
            resetIfBuilt();
            mBundle.putString(GenericDocument.NAMESPACE_FIELD, namespace);
            return mBuilderTypeInstance;
        }

        /**
         * Sets the ID of this document, changing the value provided in the constructor. No special
         * values are reserved or understood by the infrastructure.
         *
         * <p>Document IDs are unique within a namespace.
         *
         * @hide
         */
        @CanIgnoreReturnValue
        @NonNull
        public BuilderType setId(@NonNull String id) {
            Objects.requireNonNull(id);
            resetIfBuilt();
            mBundle.putString(GenericDocument.ID_FIELD, id);
            return mBuilderTypeInstance;
        }

        /**
         * Sets the schema type of this document, changing the value provided in the constructor.
         *
         * <p>To successfully index a document, the schema type must match the name of an {@link
         * AppSearchSchema} object previously provided to {@link AppSearchSession#setSchema}.
         *
         * @hide
         */
        @CanIgnoreReturnValue
        @NonNull
        public BuilderType setSchemaType(@NonNull String schemaType) {
            Objects.requireNonNull(schemaType);
            resetIfBuilt();
            mBundle.putString(GenericDocument.SCHEMA_TYPE_FIELD, schemaType);
            return mBuilderTypeInstance;
        }

        /**
         * Sets the score of the {@link GenericDocument}.
         *
         * <p>The score is a query-independent measure of the document's quality, relative to other
         * {@link GenericDocument} objects of the same {@link AppSearchSchema} type.
         *
         * <p>Results may be sorted by score using {@link SearchSpec.Builder#setRankingStrategy}.
         * Documents with higher scores are considered better than documents with lower scores.
         *
         * <p>Any non-negative integer can be used a score. By default, scores are set to 0.
         *
         * @param score any non-negative {@code int} representing the document's score.
         * @throws IllegalArgumentException if the score is negative.
         */
        @CanIgnoreReturnValue
        @NonNull
        public BuilderType setScore(@IntRange(from = 0, to = Integer.MAX_VALUE) int score) {
            if (score < 0) {
                throw new IllegalArgumentException("Document score cannot be negative.");
            }
            resetIfBuilt();
            mBundle.putInt(GenericDocument.SCORE_FIELD, score);
            return mBuilderTypeInstance;
        }

        /**
         * Sets the creation timestamp of the {@link GenericDocument}, in milliseconds.
         *
         * <p>This should be set using a value obtained from the {@link System#currentTimeMillis}
         * time base.
         *
         * <p>If this method is not called, this will be set to the time the object is built.
         *
         * @param creationTimestampMillis a creation timestamp in milliseconds.
         */
        @CanIgnoreReturnValue
        @NonNull
        public BuilderType setCreationTimestampMillis(
                @CurrentTimeMillisLong long creationTimestampMillis) {
            resetIfBuilt();
            mBundle.putLong(
                    GenericDocument.CREATION_TIMESTAMP_MILLIS_FIELD, creationTimestampMillis);
            return mBuilderTypeInstance;
        }

        /**
         * Sets the TTL (time-to-live) of the {@link GenericDocument}, in milliseconds.
         *
         * <p>The TTL is measured against {@link #getCreationTimestampMillis}. At the timestamp of
         * {@code creationTimestampMillis + ttlMillis}, measured in the {@link
         * System#currentTimeMillis} time base, the document will be auto-deleted.
         *
         * <p>The default value is 0, which means the document is permanent and won't be
         * auto-deleted until the app is uninstalled or {@link AppSearchSession#remove} is called.
         *
         * @param ttlMillis a non-negative duration in milliseconds.
         * @throws IllegalArgumentException if ttlMillis is negative.
         */
        @CanIgnoreReturnValue
        @NonNull
        public BuilderType setTtlMillis(long ttlMillis) {
            if (ttlMillis < 0) {
                throw new IllegalArgumentException("Document ttlMillis cannot be negative.");
            }
            resetIfBuilt();
            mBundle.putLong(GenericDocument.TTL_MILLIS_FIELD, ttlMillis);
            return mBuilderTypeInstance;
        }

        /**
         * Sets one or multiple {@code String} values for a property, replacing its previous values.
         *
         * @param name the name associated with the {@code values}. Must match the name for this
         *     property as given in {@link AppSearchSchema.PropertyConfig#getName}.
         * @param values the {@code String} values of the property.
         * @throws IllegalArgumentException if no values are provided, or if a passed in {@code
         *     String} is {@code null} or "".
         */
        @CanIgnoreReturnValue
        @NonNull
        public BuilderType setPropertyString(@NonNull String name, @NonNull String... values) {
            Objects.requireNonNull(name);
            Objects.requireNonNull(values);
            resetIfBuilt();
            putInPropertyBundle(name, values);
            return mBuilderTypeInstance;
        }

        /**
         * Sets one or multiple {@code boolean} values for a property, replacing its previous
         * values.
         *
         * @param name the name associated with the {@code values}. Must match the name for this
         *     property as given in {@link AppSearchSchema.PropertyConfig#getName}.
         * @param values the {@code boolean} values of the property.
         * @throws IllegalArgumentException if the name is empty or {@code null}.
         */
        @CanIgnoreReturnValue
        @NonNull
        public BuilderType setPropertyBoolean(@NonNull String name, @NonNull boolean... values) {
            Objects.requireNonNull(name);
            Objects.requireNonNull(values);
            resetIfBuilt();
            putInPropertyBundle(name, values);
            return mBuilderTypeInstance;
        }

        /**
         * Sets one or multiple {@code long} values for a property, replacing its previous values.
         *
         * @param name the name associated with the {@code values}. Must match the name for this
         *     property as given in {@link AppSearchSchema.PropertyConfig#getName}.
         * @param values the {@code long} values of the property.
         * @throws IllegalArgumentException if the name is empty or {@code null}.
         */
        @CanIgnoreReturnValue
        @NonNull
        public BuilderType setPropertyLong(@NonNull String name, @NonNull long... values) {
            Objects.requireNonNull(name);
            Objects.requireNonNull(values);
            resetIfBuilt();
            putInPropertyBundle(name, values);
            return mBuilderTypeInstance;
        }

        /**
         * Sets one or multiple {@code double} values for a property, replacing its previous values.
         *
         * @param name the name associated with the {@code values}. Must match the name for this
         *     property as given in {@link AppSearchSchema.PropertyConfig#getName}.
         * @param values the {@code double} values of the property.
         * @throws IllegalArgumentException if the name is empty or {@code null}.
         */
        @CanIgnoreReturnValue
        @NonNull
        public BuilderType setPropertyDouble(@NonNull String name, @NonNull double... values) {
            Objects.requireNonNull(name);
            Objects.requireNonNull(values);
            resetIfBuilt();
            putInPropertyBundle(name, values);
            return mBuilderTypeInstance;
        }

        /**
         * Sets one or multiple {@code byte[]} for a property, replacing its previous values.
         *
         * @param name the name associated with the {@code values}. Must match the name for this
         *     property as given in {@link AppSearchSchema.PropertyConfig#getName}.
         * @param values the {@code byte[]} of the property.
         * @throws IllegalArgumentException if no values are provided, or if a passed in {@code
         *     byte[]} is {@code null}, or if name is empty.
         */
        @CanIgnoreReturnValue
        @NonNull
        public BuilderType setPropertyBytes(@NonNull String name, @NonNull byte[]... values) {
            Objects.requireNonNull(name);
            Objects.requireNonNull(values);
            resetIfBuilt();
            putInPropertyBundle(name, values);
            return mBuilderTypeInstance;
        }

        /**
         * Sets one or multiple {@link GenericDocument} values for a property, replacing its
         * previous values.
         *
         * @param name the name associated with the {@code values}. Must match the name for this
         *     property as given in {@link AppSearchSchema.PropertyConfig#getName}.
         * @param values the {@link GenericDocument} values of the property.
         * @throws IllegalArgumentException if no values are provided, or if a passed in {@link
         *     GenericDocument} is {@code null}, or if name is empty.
         */
        @CanIgnoreReturnValue
        @NonNull
        public BuilderType setPropertyDocument(
                @NonNull String name, @NonNull GenericDocument... values) {
            Objects.requireNonNull(name);
            Objects.requireNonNull(values);
            resetIfBuilt();
            putInPropertyBundle(name, values);
            return mBuilderTypeInstance;
        }

        /**
         * Clears the value for the property with the given name.
         *
         * <p>Note that this method does not support property paths.
         *
         * @param name The name of the property to clear.
         * @hide
         */
        @CanIgnoreReturnValue
        @NonNull
        public BuilderType clearProperty(@NonNull String name) {
            Objects.requireNonNull(name);
            resetIfBuilt();
            mProperties.remove(name);
            return mBuilderTypeInstance;
        }

        private void putInPropertyBundle(@NonNull String name, @NonNull String[] values)
                throws IllegalArgumentException {
            validatePropertyName(name);
            for (int i = 0; i < values.length; i++) {
                if (values[i] == null) {
                    throw new IllegalArgumentException("The String at " + i + " is null.");
                }
            }
            mProperties.putStringArray(name, values);
        }

        private void putInPropertyBundle(@NonNull String name, @NonNull boolean[] values) {
            validatePropertyName(name);
            mProperties.putBooleanArray(name, values);
        }

        private void putInPropertyBundle(@NonNull String name, @NonNull double[] values) {
            validatePropertyName(name);
            mProperties.putDoubleArray(name, values);
        }

        private void putInPropertyBundle(@NonNull String name, @NonNull long[] values) {
            validatePropertyName(name);
            mProperties.putLongArray(name, values);
        }

        /**
         * Converts and saves a byte[][] into {@link #mProperties}.
         *
         * <p>Bundle doesn't support for two dimension array byte[][], we are converting byte[][]
         * into ArrayList<Bundle>, and each elements will contain a one dimension byte[].
         */
        private void putInPropertyBundle(@NonNull String name, @NonNull byte[][] values) {
            validatePropertyName(name);
            ArrayList<Bundle> bundles = new ArrayList<>(values.length);
            for (int i = 0; i < values.length; i++) {
                if (values[i] == null) {
                    throw new IllegalArgumentException("The byte[] at " + i + " is null.");
                }
                Bundle bundle = new Bundle();
                bundle.putByteArray(BYTE_ARRAY_FIELD, values[i]);
                bundles.add(bundle);
            }
            mProperties.putParcelableArrayList(name, bundles);
        }

        private void putInPropertyBundle(@NonNull String name, @NonNull GenericDocument[] values) {
            validatePropertyName(name);
            Parcelable[] documentBundles = new Parcelable[values.length];
            for (int i = 0; i < values.length; i++) {
                if (values[i] == null) {
                    throw new IllegalArgumentException("The document at " + i + " is null.");
                }
                documentBundles[i] = values[i].mBundle;
            }
            mProperties.putParcelableArray(name, documentBundles);
        }

        /** Builds the {@link GenericDocument} object. */
        @NonNull
        public GenericDocument build() {
            mBuilt = true;
            // Set current timestamp for creation timestamp by default.
            if (mBundle.getLong(GenericDocument.CREATION_TIMESTAMP_MILLIS_FIELD, -1) == -1) {
                mBundle.putLong(
                        GenericDocument.CREATION_TIMESTAMP_MILLIS_FIELD,
                        System.currentTimeMillis());
            }
            return new GenericDocument(mBundle);
        }

        private void resetIfBuilt() {
            if (mBuilt) {
                mBundle = BundleUtil.deepCopy(mBundle);
                // mProperties is NonNull and initialized to empty Bundle() in builder.
                mProperties = Objects.requireNonNull(mBundle.getBundle(PROPERTIES_FIELD));
                mBuilt = false;
            }
        }

        /** Method to ensure property names are not blank */
        private void validatePropertyName(@NonNull String name) {
            if (name.isEmpty()) {
                throw new IllegalArgumentException("Property name cannot be blank.");
            }
        }
    }
}
