/*
 * Copyright (c) 2015 Algolia
 * http://www.algolia.com/
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 * THE SOFTWARE.
 */

package com.algolia.search.saas;

import android.support.annotation.NonNull;
import android.support.annotation.Nullable;

import com.algolia.search.saas.helpers.DisjunctiveFaceting;

import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * A proxy to an Algolia index.
 *

 * You cannot construct this class directly. Please use {@link Client#getIndex(String)} to obtain an instance.
 *

 *

 * WARNING: For performance reasons, arguments to asynchronous methods are not cloned. Therefore, you should not
 * modify mutable arguments after they have been passed (unless explicitly noted).
 *

 */
public class Index extends Searchable {
    /** The client to which this index belongs. */
    private Client client;

    /** This index's name, not URL-encoded. */
    private String rawIndexName;

    /** This index's name, URL-encoded. Cached for optimization. */
    private String encodedIndexName;

    private ExpiringCache<String, byte[]> searchCache;
    private boolean isCacheEnabled = false;

    // ----------------------------------------------------------------------
    // Constants
    // ----------------------------------------------------------------------

    private static final long MAX_TIME_MS_TO_WAIT = 10000L;

    // ----------------------------------------------------------------------
    // Initialization
    // ----------------------------------------------------------------------

    protected Index(@NonNull Client client, @NonNull String rawIndexName) {
        try {
            this.client = client;
            this.encodedIndexName = URLEncoder.encode(rawIndexName, "UTF-8");
            this.rawIndexName = rawIndexName;
        } catch (UnsupportedEncodingException e) {
            throw new RuntimeException(e); // should never happen, as UTF-8 is always supported
        }
    }

    // ----------------------------------------------------------------------
    // Accessors
    // ----------------------------------------------------------------------

    @Override public String toString() {
        return String.format("%s{%s}", this.getClass().getSimpleName(), getRawIndexName());
    }

    public String getRawIndexName() {
        return rawIndexName;
    }

    public Client getClient() {
        return client;
    }

    protected String getEncodedIndexName() {
        return encodedIndexName;
    }

    // ----------------------------------------------------------------------
    // Public operations
    // ----------------------------------------------------------------------

    // Search
    // ------

    /**
     * Searches inside this index (asynchronously).
     *
     * @param query             Search parameters. May be null to use an empty query.
     * @param requestOptions    Request-specific options.
     * @param completionHandler The listener that will be notified of the request's outcome.
     * @return A cancellable request.
     */
    public Request searchAsync(@Nullable Query query, @Nullable final RequestOptions requestOptions, @Nullable CompletionHandler completionHandler) {
        final Query queryCopy = query != null ? new Query(query) : new Query();
        return getClient().new AsyncTaskRequest(completionHandler) {
            @NonNull
            @Override protected JSONObject run() throws AlgoliaException {
                return search(queryCopy, requestOptions);
            }
        }.start();
    }

    /**
     * Searches inside this index (synchronously).
     *
     * @param query          Search parameters.
     * @param requestOptions Request-specific options.
     * @return Search results.
     */
    public JSONObject searchSync(@Nullable Query query, @Nullable RequestOptions requestOptions) throws AlgoliaException {
        return search(query, requestOptions);
    }

    /**
     * Searches inside this index (synchronously).
     *
     * @return Search results.
     */
    public JSONObject searchSync(@Nullable Query query) throws AlgoliaException {
        return searchSync(query, /* requestOptions: */ null);
    }

    /**
     * Run multiple queries on this index with one API call.
     * A variant of {@link Client#multipleQueriesAsync(List, Client.MultipleQueriesStrategy, CompletionHandler)}
     * where the targeted index is always the receiver.
     *
     * @param queries           The queries to run.
     * @param strategy          The strategy to use.
     * @param requestOptions    Request-specific options.
     * @param completionHandler The listener that will be notified of the request's outcome.
     * @return A cancellable request.
     */
    public Request multipleQueriesAsync(final @NonNull Collection<Query> queries, final Client.MultipleQueriesStrategy strategy, @Nullable final RequestOptions requestOptions, @Nullable CompletionHandler completionHandler) {
        final List<Query> queriesCopy = new ArrayList<>(queries.size());
        for (Query query : queries) {
            queriesCopy.add(new Query(query));
        }
        return getClient().new AsyncTaskRequest(completionHandler) {
            @NonNull
            @Override protected JSONObject run() throws AlgoliaException {
                return multipleQueries(queriesCopy, strategy == null ? null : strategy.toString(), requestOptions);
            }
        }.start();
    }

    /**
     * Run multiple queries on this index with one API call.
     * A variant of {@link Client#multipleQueriesAsync(List, Client.MultipleQueriesStrategy, CompletionHandler)}
     * where the targeted index is always the receiver.
     *
     * @param queries           The queries to run.
     * @param strategy          The strategy to use.
     * @param completionHandler The listener that will be notified of the request's outcome.
     * @return A cancellable request.
     */
    public Request multipleQueriesAsync(final @NonNull Collection<Query> queries, final Client.MultipleQueriesStrategy strategy, @Nullable CompletionHandler completionHandler) {
        return multipleQueriesAsync(queries, strategy, /* requestOptions: */ null, completionHandler);
    }

    /**
     * Searches inside this index synchronously.
     *
     * @param query          Search parameters.
     * @param requestOptions Request-specific options.
     * @return Search results.
     */
    protected byte[] searchSyncRaw(@NonNull Query query, @Nullable RequestOptions requestOptions) throws AlgoliaException {
        return searchRaw(query, requestOptions);
    }

    /**
     * Perform a search with disjunctive facets, generating as many queries as number of disjunctive facets (helper).
     *
     * @param query             The query.
     * @param disjunctiveFacets List of disjunctive facets.
     * @param refinements       The current refinements, mapping facet names to a list of values.
     * @param requestOptions    Request-specific options.
     * @param completionHandler The listener that will be notified of the request's outcome.
     * @return A cancellable request.
     */
    @Override
    public Request searchDisjunctiveFacetingAsync(@NonNull Query query, @NonNull final Collection<String> disjunctiveFacets, @NonNull final Map<String, ? extends Collection<String>> refinements, @Nullable final RequestOptions requestOptions, @NonNull final CompletionHandler completionHandler) {
        return new DisjunctiveFaceting() {
            @Override
            protected Request multipleQueriesAsync(@NonNull Collection<Query> queries, @Nullable CompletionHandler completionHandler) {
                return Index.this.multipleQueriesAsync(queries, null, requestOptions, completionHandler);
            }
        }.searchDisjunctiveFacetingAsync(query, disjunctiveFacets, refinements, completionHandler);
    }

    /**
     * Perform a search with disjunctive facets, generating as many queries as number of disjunctive facets (helper).
     *
     * @param query             The query.
     * @param disjunctiveFacets List of disjunctive facets.
     * @param refinements       The current refinements, mapping facet names to a list of values.
     * @param completionHandler The listener that will be notified of the request's outcome.
     * @return A cancellable request.
     */
    public Request searchDisjunctiveFacetingAsync(@NonNull Query query, @NonNull final Collection<String> disjunctiveFacets, @NonNull final Map<String, ? extends Collection<String>> refinements, @NonNull final CompletionHandler completionHandler) {
        return searchDisjunctiveFacetingAsync(query, disjunctiveFacets, refinements, /* requestOptions: */ null, completionHandler);
    }

    /**
     * Searches (asynchronously) for some text in a facet values.
     *
     * @param facetName The name of the facet to search. It must have been declared in the index's `attributesForFaceting` setting with the `searchable()` modifier.
     * @param text      The text to search for in the facet's values.
     * @param handler   A Completion handler that will be notified of the request's outcome.
     * @return A cancellable request.
     */
    public Request searchForFacetValuesAsync(@NonNull String facetName, @NonNull String text, @NonNull final CompletionHandler handler) throws AlgoliaException {
        return searchForFacetValuesAsync(facetName, text, /* requestOptions: */ null, handler);
    }

    /**
     * Searches (asynchronously) for some text in a facet values.
     *
     * @param facetName The name of the facet to search. It must have been declared in the index's `attributesForFaceting` setting with the `searchable()` modifier.
     * @param text      The text to search for in the facet's values.
     * @param handler   A Completion handler that will be notified of the request's outcome.
     * @return A cancellable request.
     * @deprecated Please use {@link #searchForFacetValuesAsync(String, String, CompletionHandler)} instead.
     */
    public Request searchForFacetValues(@NonNull String facetName, @NonNull String text, @NonNull final CompletionHandler handler) {
        return searchForFacetValuesAsync(facetName, text, /* requestOptions: */ null, handler);
    }

    /**
     * Searches for some text in a facet values, optionally restricting the returned values to those contained in objects matching other (regular) search criteria.
     *
     * @param facetName The name of the facet to search. It must have been declared in the index's `attributesForFaceting` setting with the `searchable()` modifier.
     * @param facetText The text to search for in the facet's values.
     * @param query     An optional query to take extra search parameters into account. There parameters apply to index objects like in a regular search query. Only facet values contained in the matched objects will be returned
     * @param handler   A Completion handler that will be notified of the request's outcome.
     * @return A cancellable request.
     */
    public Request searchForFacetValuesAsync(@NonNull String facetName, @NonNull String facetText, @Nullable Query query, @NonNull final CompletionHandler handler) {
        return searchForFacetValuesAsync(facetName, facetText, query, /* requestOptions: */ null, handler);
    }

    /**
     * Searches for some text in a facet values, optionally restricting the returned values to those contained in objects matching other (regular) search criteria.
     *
     * @param facetName      The name of the facet to search. It must have been declared in the index's `attributesForFaceting` setting with the `searchable()` modifier.
     * @param facetText      The text to search for in the facet's values.
     * @param query          An optional query to take extra search parameters into account. There parameters apply to index objects like in a regular search query. Only facet values contained in the matched objects will be returned
     * @param requestOptions Request-specific options.
     * @param handler        A Completion handler that will be notified of the request's outcome.
     * @return A cancellable request.
     */
    @Override
    public Request searchForFacetValuesAsync(@NonNull String facetName, @NonNull String facetText, @Nullable Query query, @Nullable final RequestOptions requestOptions, @NonNull final CompletionHandler handler) {
        try {
            final String path = "/1/indexes/" + getEncodedIndexName() + "/facets/" + URLEncoder.encode(facetName, "UTF-8") + "/query";
            final Query params = (query != null ? new Query(query) : new Query());
            params.set("facetQuery", facetText);
            final JSONObject requestBody = new JSONObject().put("params", params.build());

            final Client client = getClient();
            return client.new AsyncTaskRequest(handler) {
                @NonNull
                @Override
                protected JSONObject run() throws AlgoliaException {
                    return client.postRequest(path, /* urlParameters: */ null, requestBody.toString(), true, requestOptions);
                }
            }.start();
        } catch (UnsupportedEncodingException | JSONException e) {
            throw new RuntimeException(e); // should never happen
        }
    }

    /**
     * Searches (asynchronously) for some text in a facet values, optionally restricting the returned values to those contained in objects matching other (regular) search criteria.
     *
     * @param facetName The name of the facet to search. It must have been declared in the index's `attributesForFaceting` setting with the `searchable()` modifier.
     * @param facetText The text to search for in the facet's values.
     * @param query     An optional query to take extra search parameters into account. There parameters apply to index objects like in a regular search query. Only facet values contained in the matched objects will be returned
     * @param handler   A Completion handler that will be notified of the request's outcome.
     * @return A cancellable request.
     * @deprecated Please use {@link #searchForFacetValuesAsync(String, String, Query, CompletionHandler)} instead.
     */
    public Request searchForFacetValues(@NonNull String facetName, @NonNull String facetText, @Nullable Query query, @NonNull final CompletionHandler handler) {
        return searchForFacetValuesAsync(facetName, facetText, query, handler);
    }

    // Manage objects
    // --------------

    /**
     * Adds an object to this index (asynchronously).
     *

     * WARNING: For performance reasons, the arguments are not cloned. Since the method is executed in the background,
     * you should not modify the object after it has been passed.
     *

     *
     * @param object            The object to add.
     * @param completionHandler The listener that will be notified of the request's outcome.
     * @return A cancellable request.
     */
    public Request addObjectAsync(final @NonNull JSONObject object, @Nullable CompletionHandler completionHandler) {
        return getClient().new AsyncTaskRequest(completionHandler) {
            @NonNull
            @Override protected JSONObject run() throws AlgoliaException {
                return addObject(object, /* requestOptions: */ null);
            }
        }.start();
    }

    /**
     * Adds an object to this index, assigning it the specified object ID (asynchronously).
     * If an object already exists with the same object ID, the existing object will be overwritten.
     *

     * WARNING: For performance reasons, the arguments are not cloned. Since the method is executed in the background,
     * you should not modify the object after it has been passed.
     *

     *
     * @param object            The object to add.
     * @param objectID          Identifier that you want to assign this object.
     * @param completionHandler The listener that will be notified of the request's outcome.
     * @return A cancellable request.
     */
    public Request addObjectAsync(final @NonNull JSONObject object, final @NonNull String objectID, @Nullable CompletionHandler completionHandler) {
        return addObjectAsync(object, objectID, /* requestOptions: */ null, completionHandler);
    }

    /**
     * Adds an object to this index, assigning it the specified object ID (asynchronously).
     * If an object already exists with the same object ID, the existing object will be overwritten.
     *

     * WARNING: For performance reasons, the arguments are not cloned. Since the method is executed in the background,
     * you should not modify the object after it has been passed.
     *

     *
     * @param object            The object to add.
     * @param objectID          Identifier that you want to assign this object.
     * @param requestOptions    Request-specific options.
     * @param completionHandler The listener that will be notified of the request's outcome.
     * @return A cancellable request.
     */
    public Request addObjectAsync(final @NonNull JSONObject object, final @NonNull String objectID, @Nullable final RequestOptions requestOptions, @Nullable CompletionHandler completionHandler) {
        return getClient().new AsyncTaskRequest(completionHandler) {
            @NonNull
            @Override protected JSONObject run() throws AlgoliaException {
                return addObject(object, objectID, requestOptions);
            }
        }.start();
    }

    /**
     * Adds several objects to this index (asynchronously).
     *
     * @param objects           Objects to add.
     * @param requestOptions    Request-specific options.
     * @param completionHandler The listener that will be notified of the request's outcome.
     * @return A cancellable request.
     */
    public Request addObjectsAsync(final @NonNull JSONArray objects, @Nullable final RequestOptions requestOptions, @Nullable CompletionHandler completionHandler) {
        return getClient().new AsyncTaskRequest(completionHandler) {
            @NonNull
            @Override protected JSONObject run() throws AlgoliaException {
                return addObjects(objects, requestOptions);
            }
        }.start();
    }

    /**
     * Adds several objects to this index (asynchronously).
     *
     * @param objects           Objects to add.
     * @param completionHandler The listener that will be notified of the request's outcome.
     * @return A cancellable request.
     */
    public Request addObjectsAsync(final @NonNull JSONArray objects, @Nullable CompletionHandler completionHandler) {
        return addObjectsAsync(objects, /* requestOptions: */ null, completionHandler);
    }

    /**
     * Update an object (asynchronously).
     *
     * @param object            New version of the object to update.
     * @param objectID          Identifier of the object to update.
     * @param completionHandler The listener that will be notified of the request's outcome.
     * @return A cancellable request.
     */
    public Request saveObjectAsync(final @NonNull JSONObject object, final @NonNull String objectID, @Nullable CompletionHandler completionHandler) {
        return saveObjectAsync(object, objectID, /* requestOptions: */ null, completionHandler);
    }

    /**
     * Update an object (asynchronously).
     *
     * @param object            New version of the object to update.
     * @param objectID          Identifier of the object to update.
     * @param requestOptions    Request-specific options.
     * @param completionHandler The listener that will be notified of the request's outcome.
     * @return A cancellable request.
     */
    public Request saveObjectAsync(final @NonNull JSONObject object, final @NonNull String objectID, @Nullable final RequestOptions requestOptions, @Nullable CompletionHandler completionHandler) {
        return getClient().new AsyncTaskRequest(completionHandler) {
            @NonNull
            @Override protected JSONObject run() throws AlgoliaException {
                return saveObject(object, objectID, requestOptions);
            }
        }.start();
    }

    /**
     * Update several objects (asynchronously).
     *
     * @param objects           Objects to update. Each object must contain an objectID attribute.
     * @param completionHandler The listener that will be notified of the request's outcome.
     * @return A cancellable request.
     */
    public Request saveObjectsAsync(final @NonNull JSONArray objects, @Nullable CompletionHandler completionHandler) {
        return saveObjectsAsync(objects, /* requestOptions: */ null, completionHandler);
    }

    /**
     * Update several objects (asynchronously).
     *
     * @param objects           Objects to update. Each object must contain an objectID attribute.
     * @param requestOptions    Request-specific options.
     * @param completionHandler The listener that will be notified of the request's outcome.
     * @return A cancellable request.
     */
    public Request saveObjectsAsync(final @NonNull JSONArray objects, @Nullable final RequestOptions requestOptions, @Nullable CompletionHandler completionHandler) {
        return getClient().new AsyncTaskRequest(completionHandler) {
            @NonNull
            @Override protected JSONObject run() throws AlgoliaException {
                return saveObjects(objects, requestOptions);
            }
        }.start();
    }

    /**
     * Partially update an object (asynchronously).
     *

     * **Note:** This method will create the object if it does not exist already. If you don't wish to, you can use
     * {@link #partialUpdateObjectAsync(JSONObject, String, boolean, CompletionHandler)} and specify `false` for the
     * `createIfNotExists` argument.
     *
     * @param partialObject     New value/operations for the object.
     * @param objectID          Identifier of object to be updated.
     * @param completionHandler The listener that will be notified of the request's outcome.
     * @return A cancellable request.
     */
    public Request partialUpdateObjectAsync(final @NonNull JSONObject partialObject, final @NonNull String objectID, @Nullable CompletionHandler completionHandler) {
        return partialUpdateObjectAsync(partialObject, objectID, /* createIfNotExists: */ true, /* requestOptions: */ null, completionHandler);
    }

    /**
     * Partially update an object (asynchronously).
     *
     * @param partialObject     New value/operations for the object.
     * @param objectID          Identifier of object to be updated.
     * @param createIfNotExists Whether the object should be created if it does not exist already.
     * @param completionHandler The listener that will be notified of the request's outcome.
     * @return A cancellable request.
     */
    public Request partialUpdateObjectAsync(final @NonNull JSONObject partialObject, final @NonNull String objectID, final boolean createIfNotExists, @Nullable CompletionHandler completionHandler) {
        return partialUpdateObjectAsync(partialObject, objectID, createIfNotExists, /* requestOptions: */ null, completionHandler);
    }

    /**
     * Partially update an object (asynchronously).
     *
     * @param partialObject     New value/operations for the object.
     * @param objectID          Identifier of object to be updated.
     * @param createIfNotExists Whether the object should be created if it does not exist already.
     * @param requestOptions    Request-specific options.
     * @param completionHandler The listener that will be notified of the request's outcome.
     * @return A cancellable request.
     */
    public Request partialUpdateObjectAsync(final @NonNull JSONObject partialObject, final @NonNull String objectID, final boolean createIfNotExists, @Nullable final RequestOptions requestOptions, @Nullable CompletionHandler completionHandler) {
        return getClient().new AsyncTaskRequest(completionHandler) {
            @NonNull
            @Override protected JSONObject run() throws AlgoliaException {
                return partialUpdateObject(partialObject, objectID, createIfNotExists, requestOptions);
            }
        }.start();
    }

    /**
     * Partially update several objects (asynchronously).
     *

     * **Note:** This method will create the objects if they do not exist already. If you don't wish to, you can use
     * {@link #partialUpdateObjectsAsync(JSONArray, boolean, CompletionHandler)} and specify `false` for the
     * `createIfNotExists` argument.
     *
     * @param partialObjects    New values/operations for the objects. Each object must contain an objectID
     *                          attribute.
     * @param completionHandler The listener that will be notified of the request's outcome.
     * @return A cancellable request.
     */
    public Request partialUpdateObjectsAsync(final @NonNull JSONArray partialObjects, @Nullable CompletionHandler completionHandler) {
        return partialUpdateObjectsAsync(partialObjects, /* createIfNotExists: */ true, /* requestOptions: */ null, completionHandler);
    }

    /**
     * Partially update several objects (asynchronously).
     *
     * @param partialObjects    New values/operations for the objects. Each object must contain an objectID
     *                          attribute.
     * @param createIfNotExists Whether objects should be created if they do not exist already.
     * @param completionHandler The listener that will be notified of the request's outcome.
     * @return A cancellable request.
     */
    public Request partialUpdateObjectsAsync(final @NonNull JSONArray partialObjects, final boolean createIfNotExists, @Nullable CompletionHandler completionHandler) {
        return partialUpdateObjectsAsync(partialObjects, createIfNotExists, /* requestOptions: */ null, completionHandler);
    }

    /**
     * Partially update several objects (asynchronously).
     *
     * @param partialObjects    New values/operations for the objects. Each object must contain an objectID
     *                          attribute.
     * @param createIfNotExists Whether objects should be created if they do not exist already.
     * @param requestOptions    Request-specific options.
     * @param completionHandler The listener that will be notified of the request's outcome.
     * @return A cancellable request.
     */
    public Request partialUpdateObjectsAsync(final @NonNull JSONArray partialObjects, final boolean createIfNotExists, @Nullable final RequestOptions requestOptions, @Nullable CompletionHandler completionHandler) {
        return getClient().new AsyncTaskRequest(completionHandler) {
            @NonNull
            @Override protected JSONObject run() throws AlgoliaException {
                return partialUpdateObjects(partialObjects, createIfNotExists, requestOptions);
            }
        }.start();
    }

    /**
     * Gets an object from this index (asynchronously).
     *
     * @param objectID          Identifier of the object to retrieve.
     * @param completionHandler The listener that will be notified of the request's outcome.
     * @return A cancellable request.
     */
    public Request getObjectAsync(final @NonNull String objectID, @Nullable CompletionHandler completionHandler) {
        return getObjectAsync(objectID, /* attributesToRetrieve: */ null, /* requestOptions: */ null, completionHandler);
    }

    /**
     * Gets an object from this index, optionally restricting the retrieved content (asynchronously).
     *
     * @param objectID             Identifier of the object to retrieve.
     * @param attributesToRetrieve List of attributes to retrieve.
     * @param completionHandler    The listener that will be notified of the request's outcome.
     * @return A cancellable request.
     */
    public Request getObjectAsync(final @NonNull String objectID, final Collection<String> attributesToRetrieve, @Nullable CompletionHandler completionHandler) {
        return getObjectAsync(objectID, attributesToRetrieve, /* requestOptions: */ null, completionHandler);
    }

    /**
     * Gets an object from this index, optionally restricting the retrieved content (asynchronously).
     *
     * @param objectID             Identifier of the object to retrieve.
     * @param attributesToRetrieve List of attributes to retrieve.
     * @param requestOptions       Request-specific options.
     * @param completionHandler    The listener that will be notified of the request's outcome.
     * @return A cancellable request.
     */
    public Request getObjectAsync(final @NonNull String objectID, final Collection<String> attributesToRetrieve, @Nullable final RequestOptions requestOptions, @Nullable CompletionHandler completionHandler) {
        return getClient().new AsyncTaskRequest(completionHandler) {
            @NonNull
            @Override protected JSONObject run() throws AlgoliaException {
                return getObject(objectID, attributesToRetrieve, requestOptions);
            }
        }.start();
    }

    /**
     * Gets several objects from this index (asynchronously).
     *
     * @param objectIDs         Identifiers of objects to retrieve.
     * @param completionHandler The listener that will be notified of the request's outcome.
     * @return A cancellable request.
     */
    public Request getObjectsAsync(final @NonNull Collection<String> objectIDs, @Nullable CompletionHandler completionHandler) {
        return getObjectsAsync(objectIDs, /* attributesToRetrieve: */ null, /* requestOptions: */ null, completionHandler);
    }

    /**
     * Gets several objects from this index (asynchronously), optionally restricting the retrieved content (asynchronously).
     *
     * @param objectIDs            Identifiers of objects to retrieve.
     * @param attributesToRetrieve List of attributes to retrieve.
     * @param completionHandler    The listener that will be notified of the request's outcome.
     * @return A cancellable request.
     */
    public Request getObjectsAsync(final @NonNull Collection<String> objectIDs, final List<String> attributesToRetrieve, @Nullable CompletionHandler completionHandler) {
        return getObjectsAsync(objectIDs, attributesToRetrieve, /* requestOptions: */ null, completionHandler);
    }

    /**
     * Gets several objects from this index (asynchronously), optionally restricting the retrieved content (asynchronously).
     *
     * @param objectIDs            Identifiers of objects to retrieve.
     * @param requestOptions       Request-specific options.
     * @param completionHandler    The listener that will be notified of the request's outcome.
     * @return A cancellable request.
     */
    public Request getObjectsAsync(final @NonNull Collection<String> objectIDs, @Nullable final RequestOptions requestOptions, @Nullable CompletionHandler completionHandler) {
        return getClient().new AsyncTaskRequest(completionHandler) {
            @NonNull
            @Override protected JSONObject run() throws AlgoliaException {
                return getObjects(objectIDs, null, requestOptions);
            }
        }.start();
    }

    /**
     * Gets several objects from this index (asynchronously), optionally restricting the retrieved content (asynchronously).
     *
     * @param objectIDs            Identifiers of objects to retrieve.
     * @param attributesToRetrieve List of attributes to retrieve.
     * @param requestOptions       Request-specific options.
     * @param completionHandler    The listener that will be notified of the request's outcome.
     * @return A cancellable request.
     */
    public Request getObjectsAsync(final @NonNull Collection<String> objectIDs, final Collection<String> attributesToRetrieve, @Nullable final RequestOptions requestOptions, @Nullable CompletionHandler completionHandler) {
        return getClient().new AsyncTaskRequest(completionHandler) {
            @NonNull
            @Override protected JSONObject run() throws AlgoliaException {
                return getObjects(objectIDs, attributesToRetrieve, requestOptions);
            }
        }.start();
    }

    /**
     * Wait until the publication of a task on the server (helper).
     * All server tasks are asynchronous. This method helps you check that a task is published.
     *
     * @param taskID            Identifier of the task (as returned by the server).
     * @param completionHandler The listener that will be notified of the request's outcome.
     * @return A cancellable request.
     */
    public Request waitTaskAsync(final @NonNull String taskID, @Nullable CompletionHandler completionHandler) {
        return getClient().new AsyncTaskRequest(completionHandler) {
            @NonNull
            @Override protected JSONObject run() throws AlgoliaException {
                return waitTask(taskID);
            }
        }.start();
    }

    /**
     * Wait until the publication of a task on the server (helper).
     * All server tasks are asynchronous. This method helps you check that a task is published.
     *
     * @param taskID            Identifier of the task (as returned by the server).
     * @param completionHandler The listener that will be notified of the request's outcome.
     * @return A cancellable request.
     */
    public Request waitTaskAsync(final long taskID, @Nullable CompletionHandler completionHandler) {
        return getClient().new AsyncTaskRequest(completionHandler) {
            @NonNull
            @Override protected JSONObject run() throws AlgoliaException {
                return waitTask(Long.toString(taskID));
            }
        }.start();
    }

    /**
     * Deletes an object from this index (asynchronously).
     *
     * @param objectID          Identifier of the object to delete.
     * @param completionHandler The listener that will be notified of the request's outcome.
     * @return A cancellable request.
     */
    public Request deleteObjectAsync(final @NonNull String objectID, @Nullable CompletionHandler completionHandler) {
        return deleteObjectAsync(objectID, /* requestOptions: */ null, completionHandler);
    }

    /**
     * Deletes an object from this index (asynchronously).
     *
     * @param objectID          Identifier of the object to delete.
     * @param requestOptions    Request-specific options.
     * @param completionHandler The listener that will be notified of the request's outcome.
     * @return A cancellable request.
     */
    public Request deleteObjectAsync(final @NonNull String objectID, @Nullable final RequestOptions requestOptions, @Nullable CompletionHandler completionHandler) {
        return getClient().new AsyncTaskRequest(completionHandler) {
            @NonNull
            @Override protected JSONObject run() throws AlgoliaException {
                return deleteObject(objectID, requestOptions);
            }
        }.start();
    }

    /**
     * Deletes several objects from this index (asynchronously).
     *
     * @param objectIDs         Identifiers of objects to delete.
     * @param completionHandler The listener that will be notified of the request's outcome.
     * @return A cancellable request.
     */
    public Request deleteObjectsAsync(final @NonNull Collection<String> objectIDs, @Nullable CompletionHandler completionHandler) {
        return deleteObjectsAsync(objectIDs, /* requestOptions: */ null, completionHandler);
    }

    /**
     * Deletes several objects from this index (asynchronously).
     *
     * @param objectIDs         Identifiers of objects to delete.
     * @param requestOptions    Request-specific options.
     * @param completionHandler The listener that will be notified of the request's outcome.
     * @return A cancellable request.
     */
    public Request deleteObjectsAsync(final @NonNull Collection<String> objectIDs, @Nullable final RequestOptions requestOptions, @Nullable CompletionHandler completionHandler) {
        return getClient().new AsyncTaskRequest(completionHandler) {
            @NonNull
            @Override protected JSONObject run() throws AlgoliaException {
                return deleteObjects(objectIDs, requestOptions);
            }
        }.start();
    }

    /**
     * Deletes all objects matching a query (helper) using browse and deleteObjects.
     *
     * @param query             The query that objects to delete must match.
     * @param completionHandler The listener that will be notified of the request's outcome.
     * @return A cancellable request.
     * @deprecated use {@link Index#deleteByAsync(Query, CompletionHandler)} instead.
     */
    public Request deleteByQueryAsync(@NonNull Query query, @Nullable CompletionHandler completionHandler) {
        return deleteByQueryAsync(query, /* requestOptions: */ null, completionHandler);
    }

    /**
     * Deletes all objects matching a query (helper).
     *
     * @param query             The query that objects to delete must match.
     * @param requestOptions    Request-specific options.
     * @param completionHandler The listener that will be notified of the request's outcome.
     * @return A cancellable request.
     * @deprecated use {@link Index#deleteByAsync(Query, CompletionHandler)} instead.
     */
    public Request deleteByQueryAsync(@NonNull Query query, @Nullable final RequestOptions requestOptions, @Nullable CompletionHandler completionHandler) {
        final Query queryCopy = new Query(query);
        return getClient().new AsyncTaskRequest(completionHandler) {
            @NonNull
            @Override protected JSONObject run() throws AlgoliaException {
                deleteByQuery(queryCopy, requestOptions);
                return new JSONObject();
            }
        }.start();
    }

    /**
     * Deletes all objects matching a query (helper).
     *
     * @param query             The query that objects to delete must match.
     * @param completionHandler The listener that will be notified of the request's outcome.
     * @return A cancellable request.
     */
    public Request deleteByAsync(@NonNull Query query, @Nullable final RequestOptions requestOptions, @Nullable CompletionHandler completionHandler) {
        final Query queryCopy = new Query(query);
        return getClient().new AsyncTaskRequest(completionHandler) {
            @NonNull
            @Override protected JSONObject run() throws AlgoliaException {
                return deleteBy(queryCopy, requestOptions);
            }
        }.start();
    }

    /**
     * Deletes all objects matching a query (helper).
     *
     * @param query             The query that objects to delete must match.
     * @param completionHandler The listener that will be notified of the request's outcome.
     * @return A cancellable request.
     */
    public Request deleteByAsync(@NonNull Query query, @Nullable CompletionHandler completionHandler) {
        return deleteByAsync(query, null, completionHandler);
    }

    /**
     * Gets this index's settings (asynchronously).
     *
     * @param completionHandler The listener that will be notified of the request's outcome.
     * @return A cancellable request.
     */
    public Request getSettingsAsync(@Nullable CompletionHandler completionHandler) {
        return getSettingsAsync(/* requestOptions */ null, completionHandler);
    }

    /**
     * Gets this index's settings (asynchronously).
     *
     * @param requestOptions    Request-specific options.
     * @param completionHandler The listener that will be notified of the request's outcome.
     * @return A cancellable request.
     */
    public Request getSettingsAsync(@Nullable final RequestOptions requestOptions, @Nullable CompletionHandler completionHandler) {
        return getClient().new AsyncTaskRequest(completionHandler) {
            @NonNull
            @Override protected JSONObject run() throws AlgoliaException {
                return getSettings(2, requestOptions);
            }
        }.start();
    }

    /**
     * Set this index's settings (asynchronously).
     *

     * Please refer to our API documentation for the
     * list of supported settings.
     *
     * @param settings          New settings.
     * @param completionHandler The listener that will be notified of the request's outcome.
     * @return A cancellable request.
     */
    public Request setSettingsAsync(final @NonNull JSONObject settings, @Nullable CompletionHandler completionHandler) {
        return setSettingsAsync(settings, /* forwardToReplicas: */ false, /* requestOptions: */ null, completionHandler);
    }

    /**
     * Set this index's settings (asynchronously).
     *

     * Please refer to our API documentation for the
     * list of supported settings.
     *
     * @param settings          New settings.
     * @param requestOptions    Request-specific options.
     * @param completionHandler The listener that will be notified of the request's outcome.
     * @return A cancellable request.
     */
    public Request setSettingsAsync(final @NonNull JSONObject settings, final boolean forwardToReplicas, @Nullable final RequestOptions requestOptions, @Nullable CompletionHandler completionHandler) {
        return getClient().new AsyncTaskRequest(completionHandler) {
            @NonNull
            @Override protected JSONObject run() throws AlgoliaException {
                return setSettings(settings, forwardToReplicas, requestOptions);
            }
        }.start();
    }

    /**
     * Browse all index content (initial call).
     * This method should be called once to initiate a browse. It will return the first page of results and a cursor,
     * unless the end of the index has been reached. To retrieve subsequent pages, call `browseFromAsync` with that
     * cursor.
     *
     * @param query             The query parameters for the browse.
     * @param completionHandler The listener that will be notified of the request's outcome.
     * @return A cancellable request.
     */
    public Request browseAsync(@NonNull Query query, @Nullable CompletionHandler completionHandler) {
        return browseAsync(query, /* requestOptions: */ null, completionHandler);
    }

    /**
     * Browse all index content (initial call).
     * This method should be called once to initiate a browse. It will return the first page of results and a cursor,
     * unless the end of the index has been reached. To retrieve subsequent pages, call `browseFromAsync` with that
     * cursor.
     *
     * @param query             The query parameters for the browse.
     * @param requestOptions    Request-specific options.
     * @param completionHandler The listener that will be notified of the request's outcome.
     * @return A cancellable request.
     */
    public Request browseAsync(@NonNull Query query, @Nullable final RequestOptions requestOptions, @Nullable CompletionHandler completionHandler) {
        final Query queryCopy = new Query(query);
        return getClient().new AsyncTaskRequest(completionHandler) {
            @NonNull
            @Override protected JSONObject run() throws AlgoliaException {
                return browse(queryCopy, requestOptions);
            }
        }.start();
    }

    /**
     * Browse the index from a cursor.
     * This method should be called after an initial call to `browseAsync()`. It returns a cursor, unless the end of
     * the index has been reached.
     *
     * @param cursor            The cursor of the next page to retrieve.
     * @param completionHandler The listener that will be notified of the request's outcome.
     * @return A cancellable request.
     */
    public Request browseFromAsync(final @NonNull String cursor, @Nullable CompletionHandler completionHandler) {
        return browseFromAsync(cursor, /* requestOptions: */ null, completionHandler);
    }

    /**
     * Browse the index from a cursor.
     * This method should be called after an initial call to `browseAsync()`. It returns a cursor, unless the end of
     * the index has been reached.
     *
     * @param cursor            The cursor of the next page to retrieve.
     * @param requestOptions    Request-specific options.
     * @param completionHandler The listener that will be notified of the request's outcome.
     * @return A cancellable request.
     */
    public Request browseFromAsync(final @NonNull String cursor, @Nullable final RequestOptions requestOptions, @Nullable CompletionHandler completionHandler) {
        return getClient().new AsyncTaskRequest(completionHandler) {
            @NonNull
            @Override protected JSONObject run() throws AlgoliaException {
                return browseFrom(cursor, requestOptions);
            }
        }.start();
    }

    /**
     * Clear this index.
     *
     * @param completionHandler The listener that will be notified of the request's outcome.
     * @return A cancellable request.
     */
    public Request clearIndexAsync(@Nullable CompletionHandler completionHandler) {
        return clearIndexAsync(/* requestOptions: */ null, completionHandler);
    }

    /**
     * Clear this index.
     *
     * @param requestOptions    Request-specific options.
     * @param completionHandler The listener that will be notified of the request's outcome.
     * @return A cancellable request.
     */
    public Request clearIndexAsync(@Nullable final RequestOptions requestOptions, @Nullable CompletionHandler completionHandler) {
        return getClient().new AsyncTaskRequest(completionHandler) {
            @NonNull
            @Override protected JSONObject run() throws AlgoliaException {
                return clearIndex(requestOptions);
            }
        }.start();
    }

    // ----------------------------------------------------------------------
    // Search cache
    // ----------------------------------------------------------------------

    /**
     * Enable search cache with default parameters
     */
    public void enableSearchCache() {
        enableSearchCache(ExpiringCache.defaultExpirationTimeout, ExpiringCache.defaultMaxSize);
    }

    /**
     * Enable search cache with custom parameters
     *
     * @param timeoutInSeconds duration during which an request is kept in cache
     * @param maxRequests      maximum amount of requests to keep before removing the least recently used
     */
    public void enableSearchCache(int timeoutInSeconds, int maxRequests) {
        isCacheEnabled = true;
        searchCache = new ExpiringCache<>(timeoutInSeconds, maxRequests);
    }

    /**
     * Disable and reset cache
     */
    public void disableSearchCache() {
        isCacheEnabled = false;
        if (searchCache != null) {
            searchCache.reset();
        }
    }

    /**
     * Remove all entries from cache
     */
    public void clearSearchCache() {
        if (searchCache != null) {
            searchCache.reset();
        }
    }

    // ----------------------------------------------------------------------
    // Internal operations
    // ----------------------------------------------------------------------

    /**
     * Adds an object in this index.
     *
     * @param obj            the object to add.
     * @param requestOptions Request-specific options.
     * @throws AlgoliaException
     */
    public JSONObject addObject(JSONObject obj, @Nullable RequestOptions requestOptions) throws AlgoliaException {
        return client.postRequest("/1/indexes/" + encodedIndexName, /* urlParameters: */ null, obj.toString(), false, requestOptions);
    }

    /**
     * Adds an object in this index.
     *
     * @param obj            the object to add.
     * @param objectID       an objectID you want to attribute to this object
     *                       (if the attribute already exist the old object will be overwrite)
     * @param requestOptions Request-specific options.
     * @throws AlgoliaException
     */
    public JSONObject addObject(JSONObject obj, String objectID, @Nullable RequestOptions requestOptions) throws AlgoliaException {
        try {
            return client.putRequest("/1/indexes/" + encodedIndexName + "/" + URLEncoder.encode(objectID, "UTF-8"), /* urlParameters: */ null, obj.toString(), requestOptions);
        } catch (UnsupportedEncodingException e) {
            throw new AlgoliaException(e.getMessage());
        }
    }

    /**
     * Custom batch.
     *
     * @param actions        the array of actions
     * @param requestOptions Request-specific options.
     * @throws AlgoliaException
     */
    protected JSONObject batch(JSONArray actions, @Nullable RequestOptions requestOptions) throws AlgoliaException {
        try {
            JSONObject content = new JSONObject();
            content.put("requests", actions);
            return client.postRequest("/1/indexes/" + encodedIndexName + "/batch", /* urlParameters: */ null, content.toString(), false, requestOptions);
        } catch (JSONException e) {
            throw new AlgoliaException(e.getMessage());
        }
    }

    /**
     * Adds several objects.
     *
     * @param inputArray     contains an array of objects to add.
     * @param requestOptions Request-specific options.
     * @throws AlgoliaException
     */
    public JSONObject addObjects(JSONArray inputArray, @Nullable RequestOptions requestOptions) throws AlgoliaException {
        try {
            JSONArray array = new JSONArray();
            for (int n = 0; n < inputArray.length(); n++) {
                JSONObject action = new JSONObject();
                action.put("action", "addObject");
                action.put("body", inputArray.getJSONObject(n));
                array.put(action);
            }
            return batch(array, requestOptions);
        } catch (JSONException e) {
            throw new AlgoliaException(e.getMessage());
        }
    }

    /**
     * Gets an object from this index.
     *
     * @param objectID       the unique identifier of the object to retrieve
     * @param requestOptions Request-specific options.
     * @throws AlgoliaException
     */
    public JSONObject getObject(String objectID, @Nullable RequestOptions requestOptions) throws AlgoliaException {
        try {
            return client.getRequest("/1/indexes/" + encodedIndexName + "/" + URLEncoder.encode(objectID, "UTF-8"), /* urlParameters: */ null, false, requestOptions);
        } catch (UnsupportedEncodingException e) {
            throw new RuntimeException(e);
        }
    }

    /**
     * Gets an object from this index.
     *
     * @param objectID             the unique identifier of the object to retrieve
     * @param attributesToRetrieve contains the list of attributes to retrieve.
     * @param requestOptions       Request-specific options.
     * @throws AlgoliaException
     */
    public JSONObject getObject(String objectID, Collection<String> attributesToRetrieve, @Nullable RequestOptions requestOptions) throws AlgoliaException {
        try {
            String path = "/1/indexes/" + encodedIndexName + "/" + URLEncoder.encode(objectID, "UTF-8");
            Map<String, String> urlParameters = new HashMap<>();
            if (attributesToRetrieve != null) {
                urlParameters.put("attributesToRetrieve", AbstractQuery.buildCommaArray(attributesToRetrieve.toArray(new String[attributesToRetrieve.size()])));
            }
            return client.getRequest(path, urlParameters, false, requestOptions);
        } catch (UnsupportedEncodingException e) {
            throw new RuntimeException(e);
        }
    }

    /**
     * Gets several objects from this index.
     *
     * @param objectIDs the array of unique identifier of objects to retrieve
     * @throws AlgoliaException
     */
    public JSONObject getObjects(Collection<String> objectIDs) throws AlgoliaException {
        return getObjects(objectIDs, /* attributesToRetrieve: */ null, /* requestOptions: */ null);
    }

    /**
     * Gets several objects from this index.
     *
     * @param objectIDs            the array of unique identifier of objects to retrieve
     * @param attributesToRetrieve contains the list of attributes to retrieve.
     * @param requestOptions       Request-specific options.
     * @throws AlgoliaException
     */
    public JSONObject getObjects(@NonNull Collection<String> objectIDs, @Nullable Collection<String> attributesToRetrieve, @Nullable RequestOptions requestOptions) throws AlgoliaException {
        try {
            JSONArray requests = new JSONArray();
            for (String id : objectIDs) {
                JSONObject request = new JSONObject();
                request.put("indexName", this.rawIndexName);
                request.put("objectID", id);
                if (attributesToRetrieve != null) {
                    request.put("attributesToRetrieve", new JSONArray(attributesToRetrieve));
                }
                requests.put(request);
            }
            JSONObject body = new JSONObject();
            body.put("requests", requests);
            return client.postRequest("/1/indexes/*/objects", /* urlParameters: */ null, body.toString(), true, requestOptions);
        } catch (JSONException e) {
            throw new AlgoliaException(e.getMessage());
        }
    }

    /**
     * Update partially an object (only update attributes passed in argument).
     *
     * @param partialObject  the object attributes to override
     * @param requestOptions Request-specific options.
     * @throws AlgoliaException
     */
    public JSONObject partialUpdateObject(JSONObject partialObject, String objectID, Boolean createIfNotExists, @Nullable RequestOptions requestOptions) throws AlgoliaException {
        try {
            String path = "/1/indexes/" + encodedIndexName + "/" + URLEncoder.encode(objectID, "UTF-8") + "/partial";
            Map<String, String> urlParameters = new HashMap<>();
            if (createIfNotExists != null) {
                urlParameters.put("createIfNotExists", createIfNotExists.toString());
            }
            return client.postRequest(path, urlParameters, partialObject.toString(), false, requestOptions);
        } catch (UnsupportedEncodingException e) {
            throw new RuntimeException(e);
        }
    }

    /**
     * Partially Override the content of several objects.
     *
     * @param inputArray     the array of objects to update (each object must contains an objectID attribute)
     * @param requestOptions Request-specific options.
     * @throws AlgoliaException
     */
    public JSONObject partialUpdateObjects(JSONArray inputArray, boolean createIfNotExists, @Nullable RequestOptions requestOptions) throws AlgoliaException {
        try {
            final String action = createIfNotExists ? "partialUpdateObject" : "partialUpdateObjectNoCreate";
            JSONArray array = new JSONArray();
            for (int n = 0; n < inputArray.length(); n++) {
                JSONObject obj = inputArray.getJSONObject(n);
                JSONObject operation = new JSONObject();
                operation.put("action", action);
                operation.put("objectID", obj.getString("objectID"));
                operation.put("body", obj);
                array.put(operation);
            }
            return batch(array, requestOptions);
        } catch (JSONException e) {
            throw new AlgoliaException(e.getMessage());
        }
    }

    /**
     * Override the content of object.
     *
     * @param object         the object to save
     * @param requestOptions Request-specific options.
     * @throws AlgoliaException
     */
    public JSONObject saveObject(JSONObject object, String objectID, @Nullable RequestOptions requestOptions) throws AlgoliaException {
        try {
            return client.putRequest("/1/indexes/" + encodedIndexName + "/" + URLEncoder.encode(objectID, "UTF-8"), /* urlParameters: */ null, object.toString(), requestOptions);
        } catch (UnsupportedEncodingException e) {
            throw new RuntimeException(e);
        }
    }

    /**
     * Override the content of several objects.
     *
     * @param inputArray     contains an array of objects to update (each object must contains an objectID attribute)
     * @param requestOptions Request-specific options.
     * @throws AlgoliaException
     */
    public JSONObject saveObjects(JSONArray inputArray, @Nullable RequestOptions requestOptions) throws AlgoliaException {
        try {
            JSONArray array = new JSONArray();
            for (int n = 0; n < inputArray.length(); n++) {
                JSONObject obj = inputArray.getJSONObject(n);
                JSONObject action = new JSONObject();
                action.put("action", "updateObject");
                action.put("objectID", obj.getString("objectID"));
                action.put("body", obj);
                array.put(action);
            }
            return batch(array, requestOptions);
        } catch (JSONException e) {
            throw new AlgoliaException(e.getMessage());
        }
    }

    /**
     * Deletes an object from the index.
     *
     * @param objectID       the unique identifier of object to delete
     * @param requestOptions Request-specific options.
     * @throws AlgoliaException
     */
    public JSONObject deleteObject(String objectID, @Nullable RequestOptions requestOptions) throws AlgoliaException {
        if (objectID.length() == 0) {
            throw new AlgoliaException("Invalid objectID");
        }
        try {
            return client.deleteRequest("/1/indexes/" + encodedIndexName + "/" + URLEncoder.encode(objectID, "UTF-8"), /* urlParameters: */ null, requestOptions);
        } catch (UnsupportedEncodingException e) {
            throw new RuntimeException(e);
        }
    }

    /**
     * Deletes several objects.
     *
     * @param objects        the array of objectIDs to delete
     * @param requestOptions Request-specific options.
     * @throws AlgoliaException
     */
    public JSONObject deleteObjects(Collection<String> objects, @Nullable RequestOptions requestOptions) throws AlgoliaException {
        try {
            JSONArray array = new JSONArray();
            for (String id : objects) {
                JSONObject obj = new JSONObject();
                obj.put("objectID", id);
                JSONObject action = new JSONObject();
                action.put("action", "deleteObject");
                action.put("body", obj);
                array.put(action);
            }
            return batch(array, requestOptions);
        } catch (JSONException e) {
            throw new AlgoliaException(e.getMessage());
        }
    }

    /**
     * Deletes all objects matching a query using browse and deleteObjects.
     *
     * @param query          the query string
     * @param requestOptions Request-specific options.
     * @throws AlgoliaException
     * @deprecated use {@link Index#deleteBy(Query)} instead.
     */
    @Deprecated
    public void deleteByQuery(@NonNull Query query, @Nullable RequestOptions requestOptions) throws AlgoliaException {
        try {
            boolean hasMore;
            do {
                // Browse index for the next batch of objects.
                // WARNING: Since deletion invalidates cursors, we always browse from the start.
                List<String> objectIDs = new ArrayList<>(1000);
                JSONObject content = browse(query, requestOptions);
                JSONArray hits = content.getJSONArray("hits");
                for (int i = 0; i < hits.length(); ++i) {
                    JSONObject hit = hits.getJSONObject(i);
                    objectIDs.add(hit.getString("objectID"));
                }
                hasMore = content.optString("cursor", null) != null;

                // Delete objects.
                JSONObject task = this.deleteObjects(objectIDs, /* requestOptions: */ null);
                this.waitTask(task.getString("taskID"));
            }
            while (hasMore);
        } catch (JSONException e) {
            throw new AlgoliaException(e.getMessage());
        }
    }

    /**
     * Deletes all records matching the query.
     *
     * @param query the query string
     * @throws AlgoliaException
     */
    public JSONObject deleteBy(@NonNull Query query) throws AlgoliaException {
        return deleteBy(query, null);
    }

    /**
     * Deletes all records matching the query.
     *
     * @param query the query string
     * @throws AlgoliaException
     *
     */
    public JSONObject deleteBy(@NonNull Query query, RequestOptions requestOptions) throws AlgoliaException {
        try {
            return client.postRequest("/1/indexes/" + encodedIndexName + "/deleteByQuery", query.getParameters(), new JSONObject().put("params", query.build()).toString(), false, requestOptions);
        } catch (JSONException e) {
            e.printStackTrace();
            return null;
        }
    }

    /**
     * Searches inside the index.
     *
     * @param requestOptions Request-specific options.
     * @return a JSONObject containing search results
     * @throws AlgoliaException
     */
    public JSONObject search(@Nullable Query query, @Nullable RequestOptions requestOptions) throws AlgoliaException {
        if (query == null) {
            query = new Query();
        }

        String cacheKey = null;
        byte[] rawResponse = null;
        if (isCacheEnabled) {
            cacheKey = query.build();
            rawResponse = searchCache.get(cacheKey);
        }
        try {
            if (rawResponse == null) {
                rawResponse = searchRaw(query, requestOptions);
                if (isCacheEnabled) {
                    searchCache.put(cacheKey, rawResponse);
                }
            }
            return Client._getJSONObject(rawResponse);
        } catch (UnsupportedEncodingException | JSONException e) {
            throw new AlgoliaException(e.getMessage());
        }
    }

    /**
     * Searches inside the index.
     *
     * @param requestOptions Request-specific options.
     * @return a byte array containing search results
     * @throws AlgoliaException
     */
    protected byte[] searchRaw(@Nullable Query query, @Nullable RequestOptions requestOptions) throws AlgoliaException {
        if (query == null) {
            query = new Query();
        }

        try {
            String paramsString = query.build();
            if (paramsString.length() > 0) {
                JSONObject body = new JSONObject();
                body.put("params", paramsString);
                return client.postRequestRaw("/1/indexes/" + encodedIndexName + "/query", /* urlParameters: */ null, body.toString(), true, requestOptions);
            } else {
                return client.getRequestRaw("/1/indexes/" + encodedIndexName, /* urlParameters: */ null, true, requestOptions);
            }
        } catch (JSONException e) {
            throw new RuntimeException(e); // should never happen
        }
    }

    /**
     * Wait the publication of a task on the server.
     * All server task are asynchronous and you can check with this method that the task is published.
     *
     * @param taskID     the id of the task returned by server
     * @param timeToWait time to sleep seed
     * @throws AlgoliaException
     */
    public JSONObject waitTask(String taskID, long timeToWait) throws AlgoliaException {
        try {
            while (true) {
                JSONObject obj = client.getRequest("/1/indexes/" + encodedIndexName + "/task/" + URLEncoder.encode(taskID, "UTF-8"), /* urlParameters: */ null, false, /* requestOptions: */ null);
                if (obj.getString("status").equals("published")) {
                    return obj;
                }
                try {
                    Thread.sleep(timeToWait >= MAX_TIME_MS_TO_WAIT ? MAX_TIME_MS_TO_WAIT : timeToWait);
                } catch (InterruptedException e) {
                    continue;
                }

                final long newTimeout = timeToWait * 2;
                timeToWait = (newTimeout <= 0 || newTimeout >= MAX_TIME_MS_TO_WAIT) ? MAX_TIME_MS_TO_WAIT : newTimeout;
            }
        } catch (JSONException e) {
            throw new AlgoliaException(e.getMessage());
        } catch (UnsupportedEncodingException e) {
            throw new RuntimeException(e);
        }
    }

    /**
     * Waits for the publication of a task on the server.
     * All server task are asynchronous and you can check with this method that the task is published.
     *
     * @param taskID the id of the task returned by server
     * @throws AlgoliaException
     */
    public JSONObject waitTask(String taskID) throws AlgoliaException {
        return waitTask(taskID, MAX_TIME_MS_TO_WAIT);
    }

    public static final int DEFAULT_SETTINGS_VERSION = 2;

    /**
     * Gets the settings of this index.
     *
     * @param requestOptions Request-specific options.
     */
    public JSONObject getSettings(@Nullable RequestOptions requestOptions) throws AlgoliaException {
        Map<String, String> urlParameters = new HashMap<>();
        urlParameters.put("getVersion", Integer.toString(DEFAULT_SETTINGS_VERSION));
        return client.getRequest("/1/indexes/" + encodedIndexName + "/settings", urlParameters, false, requestOptions);
    }

    /**
     * Gets the settings of this index for a specific settings format.
     *
     * @param formatVersion  the version of a settings format.
     * @param requestOptions Request-specific options.
     * @throws AlgoliaException
     */
    public JSONObject getSettings(int formatVersion, @Nullable RequestOptions requestOptions) throws AlgoliaException {
        Map<String, String> urlParameters = new HashMap<>();
        urlParameters.put("getVersion", Integer.toString(formatVersion));
        return client.getRequest("/1/indexes/" + encodedIndexName + "/settings", urlParameters, false, requestOptions);
    }

    /**
     * Set settings for this index, forwarding to its replicas.
     *
     * @param settings          the settings object.
     * @throws AlgoliaException
     */
    public JSONObject setSettings(JSONObject settings) throws AlgoliaException {
        return setSettings(settings, true, null);
    }

    /**
     * Set settings for this index.
     *
     * @param settings          the settings object.
     * @param forwardToReplicas if true, the new settings will be forwarded to replica indices.
     * @param requestOptions    Request-specific options.
     * @throws AlgoliaException
     */
    public JSONObject setSettings(JSONObject settings, boolean forwardToReplicas, @Nullable RequestOptions requestOptions) throws AlgoliaException {
        Map<String, String> urlParameters = new HashMap<>();
        urlParameters.put("forwardToReplicas", Boolean.toString(forwardToReplicas));
        return client.putRequest("/1/indexes/" + encodedIndexName + "/settings", urlParameters, settings.toString(), requestOptions);
    }

    /**
     * Deletes the index content without removing settings and index specific API keys.
     *
     * @param requestOptions Request-specific options.
     * @throws AlgoliaException
     */
    public JSONObject clearIndex(@Nullable RequestOptions requestOptions) throws AlgoliaException {
        return client.postRequest("/1/indexes/" + encodedIndexName + "/clear", /* urlParameters: */ null, "", false, requestOptions);
    }

    protected JSONObject browse(@NonNull Query query, @Nullable RequestOptions requestOptions) throws AlgoliaException {
        return client.getRequest("/1/indexes/" + encodedIndexName + "/browse", query.getParameters(), true, requestOptions);
    }

    protected JSONObject browseFrom(@NonNull String cursor, @Nullable RequestOptions requestOptions) throws AlgoliaException {
        Map<String, String> urlParameters = new HashMap<>();
        urlParameters.put("cursor", cursor);
        return client.getRequest("/1/indexes/" + encodedIndexName + "/browse", urlParameters, true, requestOptions);
    }

    /**
     * Run multiple queries on this index with one API call.
     * A variant of {@link Client#multipleQueries(List, String, RequestOptions)} where all queries target this index.
     *
     * @param queries        Queries to run.
     * @param strategy       Strategy to use.
     * @param requestOptions Request-specific options.
     * @return The JSON results returned by the server.
     * @throws AlgoliaException
     */
    protected JSONObject multipleQueries(@NonNull Collection<Query> queries, String strategy, @Nullable RequestOptions requestOptions) throws AlgoliaException {
        List<IndexQuery> requests = new ArrayList<>(queries.size());
        for (Query query : queries) {
            requests.add(new IndexQuery(this, query));
        }
        return client.multipleQueries(requests, strategy, requestOptions);
    }
}


/*
 * Copyright (c) 2016 Algolia
 * http://www.algolia.com/
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 * THE SOFTWARE.
 */

package com.algolia.search.saas;

import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.text.TextUtils;

import org.json.JSONArray;
import org.json.JSONException;

import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.util.Map;
import java.util.TreeMap;

// ----------------------------------------------------------------------
// IMPLEMENTATION NOTES
// ----------------------------------------------------------------------
// The query parameters are stored as an untyped map of strings.
// This class provides:
// - low-level accessors to the untyped parameters;
// - higher-level, typed accessors.
// The latter simply serialize their values into the untyped map and parse
// them back from it.
// ----------------------------------------------------------------------

/**
 * An abstract search query.
 */
public abstract class AbstractQuery {

    // ----------------------------------------------------------------------
    // Types
    // ----------------------------------------------------------------------

    /**
     * A pair of (latitude, longitude).
     * Used in geo-search.
     */
    public static final class LatLng {
        public final double lat;
        public final double lng;

        public LatLng(double lat, double lng) {
            this.lat = lat;
            this.lng = lng;
        }

        @Override
        public boolean equals(@Nullable Object other) {
            return other != null && other instanceof LatLng
                    && this.lat == ((LatLng)other).lat && this.lng == ((LatLng)other).lng;
        }

        @Override
        public int hashCode() {
            return (int)Math.round(lat * lng % Integer.MAX_VALUE);
        }

        /**
         * Parse a `LatLng` from its string representation.
         *
         * @param value A string representation of a (latitude, longitude) pair, in the format `12.345,67.890`
         *              (number of digits may vary).
         * @return A `LatLng` instance describing the given geolocation, or `null` if `value` is `null` or does not
         *         represent a valid geolocation.
         */
        @Nullable public static LatLng parse(@Nullable String value) {
            if (value == null) {
                return null;
            }
            String[] components = value.split(",");
            if (components.length != 2) {
                return null;
            }
            try {
                return new LatLng(Double.valueOf(components[0]), Double.valueOf(components[1]));
            } catch (NumberFormatException e) {
                return null;
            }
        }
    }

    // ----------------------------------------------------------------------
    // Fields
    // ----------------------------------------------------------------------

    /** Query parameters, as an untyped key-value array. */
    // NOTE: Using a tree map to have parameters sorted by key on output.
    @NonNull private Map<String, String> parameters = new TreeMap<>();

    /**
     * Access the store of query parameters. For internal use only.
     * @return The parameters in this query.
     */
    Map<String, String> getParameters() {
        return parameters;
    }

    // ----------------------------------------------------------------------
    // Construction
    // ----------------------------------------------------------------------

    /**
     * Construct an empty query.
     */
    protected AbstractQuery() {
    }

    /**
     * Clone an existing query.
     * @param other The query to be cloned.
     */
    protected AbstractQuery(@NonNull AbstractQuery other) {
        parameters = new TreeMap<>(other.parameters);
    }

    // ----------------------------------------------------------------------
    // Equality
    // ----------------------------------------------------------------------

    @Override
    public boolean equals(@Nullable Object other) {
        return other != null && other instanceof AbstractQuery && this.parameters.equals(((AbstractQuery)other).parameters);
    }

    @Override
    public int hashCode() {
        return parameters.hashCode();
    }

    // ----------------------------------------------------------------------
    // Misc.
    // ----------------------------------------------------------------------

    /**
     * Obtain a debug representation of this query.
     * To get the raw query URL part, please see {@link #build()}.
     * @return A debug representation of this query.
     */
    @Override public @NonNull String toString() {
        return String.format("%s{%s}", this.getClass().getSimpleName(), this.build());
    }

    // ----------------------------------------------------------------------
    // Parsing/serialization
    // ----------------------------------------------------------------------

    /**
     * Build the URL query parameter string representing this object.
     * @return A string suitable for use inside the query part of a URL (i.e. after the question mark).
     */
    public @NonNull String build() {
        return build(parameters);
    }

    /**
     * Build a query string from a map of URL parameters.
     * @return A string suitable for use inside the query part of a URL (i.e. after the question mark).
     */
    static @NonNull String build(@NonNull Map<String, String> parameters) {
        StringBuilder stringBuilder = new StringBuilder();
        try {
            for (Map.Entry<String, String> entry : parameters.entrySet()) {
                String key = entry.getKey();
                if (stringBuilder.length() > 0)
                    stringBuilder.append('&');
                stringBuilder.append(urlEncode(key));
                String value = entry.getValue();
                if (value != null) {
                    stringBuilder.append('=');
                    stringBuilder.append(urlEncode(value));
                }
            }
        } catch (UnsupportedEncodingException e) {
            throw new RuntimeException(e); // should never happen: UTF-8 is always supported
        }
        return stringBuilder.toString();
    }

    static private String urlEncode(String value) throws UnsupportedEncodingException {
        // NOTE: We prefer to have space encoded as `%20` instead of `+`, so we patch `URLEncoder`'s behaviour.
        // This works because `+` itself is percent-escaped (into `%2B`).
        return URLEncoder.encode(value, "UTF-8").replace("+", "%20");
    }

    /**
     * Parse a URL query parameter string and store the resulting parameters into this query.
     * @param queryParameters URL query parameter string.
     */
    public void parseFrom(@NonNull String queryParameters) {
        try {
            String[] parameters = queryParameters.split("&");
            for (String parameter : parameters) {
                String[] components = parameter.split("=");
                if (components.length < 1 || components.length > 2)
                    continue; // ignore invalid values
                String name = URLDecoder.decode(components[0], "UTF-8");
                String value = components.length >= 2 ? URLDecoder.decode(components[1], "UTF-8") : null;
                set(name, value);
            } // for each parameter
        } catch (UnsupportedEncodingException e) {
            // Should never happen since UTF-8 is one of the default encodings.
            throw new RuntimeException(e);
        }
    }

    protected static @Nullable Boolean parseBoolean(@Nullable String value) {
        if (value == null) {
            return null;
        }
        if (value.trim().toLowerCase().equals("true")) {
            return true;
        }
        Integer intValue = parseInt(value);
        return intValue != null && intValue != 0;
    }

    protected static @Nullable Integer parseInt(@Nullable String value)  {
        if (value == null) {
            return null;
        }
        try {
            return Integer.parseInt(value.trim());
        } catch (NumberFormatException e) {
            return null;
        }
    }

    protected static @NonNull String buildJSONArray(@NonNull String[] values) {
        JSONArray array = new JSONArray();
        for (String value : values) {
            array.put(value);
        }
        return array.toString();
    }

    @Nullable protected static String[] parseArray(@Nullable String string) {
        if (string == null) {
            return null;
        }
        // First try to parse JSON notation.
        try {
            JSONArray array = new JSONArray(string);
            String[] result = new String[array.length()];
            for (int i = 0; i < result.length; ++i) {
                result[i] = array.optString(i);
            }
            return result;
        }
        // Otherwise parse as a comma-separated list.
        catch (JSONException e) {
            return string.split(",");
        }
    }

    protected static String buildCommaArray(String[] values) {
        return TextUtils.join(",", values);
    }

    protected static @Nullable String[] parseCommaArray(@Nullable String string) {
        return string == null ? null : string.split(",");
    }

    /**
     * @deprecated Please use {@link LatLng#parse(String)} instead.
     */
    public static @Nullable LatLng parseLatLng(String value) {
        return LatLng.parse(value);
    }

    // ----------------------------------------------------------------------
    // Low-level (untyped) accessors
    // ----------------------------------------------------------------------

    /**
     * Set a parameter in an untyped fashion.
     * This low-level accessor is intended to access parameters that this client does not yet support.
     * @param name The parameter's name.
     * @param value The parameter's value, or null to remove it.
     *              It will first be converted to a String by the `toString()` method.
     * @return This instance (used to chain calls).
     */
    public @NonNull AbstractQuery set(@NonNull String name, @Nullable Object value) {
        if (value == null) {
            parameters.remove(name);
        } else {
            parameters.put(name, value.toString());
        }
        return this;
    }

    /**
     * Get a parameter in an untyped fashion.
     * @param name The parameter's name.
     * @return The parameter's value, or null if a parameter with the specified name does not exist.
     */
    public @Nullable String get(@NonNull String name) {
        return parameters.get(name);
    }
}