/*
 * Copyright (c) 2012-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.content.res.Resources;
import android.os.Looper;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.util.Log;

import com.algolia.search.offline.core.LocalIndex;
import com.algolia.search.offline.core.Response;
import com.algolia.search.saas.helpers.DisjunctiveFaceting;

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

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.io.Writer;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;

/**
 * A purely offline index.
 * Such an index has no online counterpart. It is updated and queried locally.
 *
 * **Note:** You cannot construct this class directly. Please use {@link OfflineClient#getOfflineIndex(String)} to
 * obtain an instance.
 *
 * **Note:** Requires Algolia Offline Core. {@link OfflineClient#enableOfflineMode(String)} must be called with a
 * valid license key prior to calling any offline-related method.
 *
 *
 * ## Reading
 *
 * Read operations behave identically as with online indices.
 *
 *
 * ## Writing
 *
 * Updating an index involves rebuilding it, which is an expensive and potentially lengthy operation. Therefore, all
 * updates must be wrapped inside a **transaction**.
 *
 * The procedure to update an index is as follows:
 *
 * - Create a transaction by calling {@link #newTransaction()}.
 *
 * - Populate the transaction: call the various write methods on the {@link WriteTransaction WriteTransaction} class.
 *
 * - Either commit or rollback the transaction.
 *
 * ### Synchronous vs asynchronous updates
 *
 * Any write operation, especially (but not limited to) the final commit, is potentially lengthy. This is why all
 * operations provide an asynchronous version, which accepts an optional completion handler that will be notified of
 * the operation's completion (either successful or erroneous).
 *
 * If you already have a background thread/queue performing data-handling tasks, you may find it more convenient to
 * use the synchronous versions of the write methods. They are named after the asynchronous versions, suffixed by
 * `Sync`. The flow is identical to the asynchronous version (see above).
 *
 * **Warning:** You must not call synchronous methods from the main thread. The methods will throw an
 * `IllegalStateException` if you do so.
 *
 * **Note:** The synchronous methods can throw; you have to catch and handle the exception.
 *
 * ### Parallel transactions
 *
 * While it is possible to create parallel transactions, there is little interest in doing so, since each committed
 * transaction results in an index rebuild. Multiplying transactions therefore only degrades performance.
 *
 * Also, transactions are serially executed in the order they were committed, the latest transaction potentially
 * overwriting the previous transactions' result.
 *
 * ### Manual build
 *
 * As an alternative to using write transactions, `OfflineIndex` also offers a **manual build** feature. Provided that
 * you have:
 *
 * - the **index settings** (one JSON file); and
 * - the **objects** (as many JSON files as needed, each containing an array of objects)
 *
 * ... available as local files on disk, you can replace the index's content with that data by calling
 * {@link #buildFromFiles buildFromFiles} or {@link #buildFromRawResources buildFromRawResources}.
 *
 *
 * ## Caveats
 *
 * ### Limitations
 *
 * Though offline indices support most features of an online index, there are some limitations:
 *
 * - Objects **must contain an `objectID`** field. The SDK will refuse to index objects without an ID.
 *
 * - **Partial updates** are not supported.
 *
 * - **Replica indices** are not supported.
 *
 * ### Differences
 *
 * - **Settings** are not incremental: the new settings completely replace the previous ones. If a setting
 *   is omitted in the new version, it reverts back to its default value. (This is in contrast with the online API,
 *   where you can only specify the settings you want to change and omit the others.)
 *
 * - You cannot batch arbitrary write operations in a single method call (as you would do with
 *   {@link Client#batchAsync Client.batchAsync}). However, all write operations are *de facto* batches, since they
 *   must be wrapped inside a transaction (see below).
 */
public class OfflineIndex {
    /** The client to which this index belongs. */
    private final OfflineClient client;

    /** This index's name. */
    private final String name;

    /** Local index backing this offline index. */
    private final LocalIndex localIndex;

    /** Serial number for transactions. */
    private int transactionSeqNo = 0;

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

    /**
     * Create a new offline index.
     *
     * @param client Offline client to be used by the index.
     * @param name Index name.
     */
    protected OfflineIndex(@NonNull OfflineClient client, @NonNull String name) {
        this.client = client;
        this.name = name;
        this.localIndex = new LocalIndex(getClient().getRootDataDir().getAbsolutePath(), getClient().getApplicationID(), name);
    }

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

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

    /**
     * Get this index's name.
     *
     * @return This index's name.
     */
    public String getName() {
        return name;
    }

    /**
     * Get the client to which this index belongs.
     *
     * @return The client to which this index belongs.
     */
    public OfflineClient getClient()
    {
        return client;
    }

    // ----------------------------------------------------------------------
    // Read operations
    // ----------------------------------------------------------------------

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

    /**
     * Search inside this index (synchronously).
     *
     * @return Search results.
     */
    private JSONObject searchSync(@NonNull Query query) throws AlgoliaException {
        return OfflineClient.parseSearchResults(localIndex.search(query.build()));
    }

    /**
     * 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 List<Query> queries, @Nullable final Client.MultipleQueriesStrategy strategy, @NonNull CompletionHandler completionHandler) {
        final List<Query> queriesCopy = new ArrayList<>(queries);
        return getClient().new AsyncTaskRequest(completionHandler) {
            @NonNull
            @Override
            protected JSONObject run() throws AlgoliaException {
                return multipleQueriesSync(queriesCopy, strategy);
            }
        }.start();
    }

    private JSONObject multipleQueriesSync(final @NonNull List<Query> queries, @Nullable final Client.MultipleQueriesStrategy strategy) throws AlgoliaException {
        return new MultipleQueryEmulator(name) {
            @Override
            protected JSONObject singleQuery(@NonNull Query query) throws AlgoliaException {
                return searchSync(query);
            }
        }.multipleQueries(queries, strategy == null ? null : strategy.toString());
    }

    /**
     * Get 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, @NonNull CompletionHandler completionHandler) {
        return getObjectAsync(objectID, null, completionHandler);
    }

    /**
     * Get 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 List<String> attributesToRetrieve, @NonNull CompletionHandler completionHandler) {
        return getClient().new AsyncTaskRequest(completionHandler) {
            @NonNull
            @Override
            protected JSONObject run() throws AlgoliaException {
                return getObjectSync(objectID, attributesToRetrieve);
            }
        }.start();
    }

    private JSONObject getObjectSync(@NonNull String objectID, List<String> attributesToRetrieve) throws AlgoliaException {
        try {
            JSONObject content = getObjectsSync(Collections.singletonList(objectID), attributesToRetrieve);
            JSONArray results = content.getJSONArray("results");
            return results.getJSONObject(0);
        }
        catch (JSONException e) {
            throw new AlgoliaException("Invalid JSON returned", e);
        }
    }

    /**
     * Get 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 List<String> objectIDs, @NonNull CompletionHandler completionHandler) {
        return getClient().new AsyncTaskRequest(completionHandler) {
            @NonNull
            @Override
            protected JSONObject run() throws AlgoliaException {
                return getObjectsSync(objectIDs, null);
            }
        }.start();
    }

    private JSONObject getObjectsSync(@NonNull List<String> objectIDs, List<String> attributesToRetrieve) throws AlgoliaException {
        final String[] objectIDsAsArray = objectIDs.toArray(new String[objectIDs.size()]);
        final String queryParameters = attributesToRetrieve == null ? null : new Query().setAttributesToRetrieve(attributesToRetrieve.toArray(new String[attributesToRetrieve.size()])).build();
        return OfflineClient.parseSearchResults(localIndex.getObjects(objectIDsAsArray, queryParameters));
    }

    /**
     * Get 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(@NonNull CompletionHandler completionHandler) {
        return getClient().new AsyncTaskRequest(completionHandler) {
            @NonNull
            @Override
            protected JSONObject run() throws AlgoliaException {
                return getSettingsSync();
            }
        }.start();
    }

    private JSONObject getSettingsSync() throws AlgoliaException {
        return OfflineClient.parseSearchResults(localIndex.getSettings());
    }

    /**
     * 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, @NonNull CompletionHandler completionHandler) {
        final Query queryCopy = new Query(query);
        return getClient().new AsyncTaskRequest(completionHandler) {
            @NonNull
            @Override
            protected JSONObject run() throws AlgoliaException {
                return browseSync(queryCopy);
            }
        }.start();
    }

    private JSONObject browseSync(@NonNull Query query) throws AlgoliaException {
        return OfflineClient.parseSearchResults(localIndex.browse(query.build()));
    }

    /**
     * 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, @NonNull CompletionHandler completionHandler) {
        return getClient().new AsyncTaskRequest(completionHandler) {
            @NonNull
            @Override
            protected JSONObject run() throws AlgoliaException {
                return browseFromSync(cursor);
            }
        }.start();
    }

    private JSONObject browseFromSync(@NonNull String cursor) throws AlgoliaException {
        final Query query = new Query().set("cursor", cursor);
        return OfflineClient.parseSearchResults(localIndex.browse(query.build()));
    }

    /**
     * Search for facet values (asynchronously).
     * Same parameters as {@link Index#searchForFacetValues(String, String, Query, CompletionHandler)}.
     */
    public Request searchForFacetValuesAsync(final @NonNull String facetName, final @NonNull String facetQuery, @Nullable Query query, @NonNull CompletionHandler completionHandler) {
        final Query queryCopy = query != null ? new Query(query) : null;
        return getClient().new AsyncTaskRequest(completionHandler) {
            @NonNull
            @Override
            protected JSONObject run() throws AlgoliaException {
                return searchForFacetValuesSync(facetName, facetQuery, queryCopy);
            }
        }.start();
    }

    /**
     * Search for facet values (synchronously).
     */
    private JSONObject searchForFacetValuesSync(@NonNull String facetName, @NonNull String facetQuery, @Nullable Query query) throws AlgoliaException {
        return OfflineClient.parseSearchResults(localIndex.searchForFacetValues(facetName, facetQuery, query != null ? query.build() : null));
    }

    // ----------------------------------------------------------------------
    // Transaction management
    // ----------------------------------------------------------------------

    /**
     * A transaction to update the index.
     *
     * A transaction gathers all the operations that will be performed when the transaction is committed.
     * Its purpose is twofold:
     *
     * 1. Avoid rebuilding the index for every individual operation, which would be astonishingly costly.
     * 2. Avoid keeping all the necessary data in memory, e.g. by flushing added objects to temporary files on disk.
     *
     * A transaction can be created by calling {@link #newTransaction()}.
     */
    public class WriteTransaction {

        /**
         * This transaction's ID.
         * Unique within the context of `index`.
         * *Not* guaranteed to be unique across all indices.
         */
        private int id;

        /** Whether this transaction has completed (committed or rolled back). */
        private boolean finished = false;

        /**
         * Path to the temporary file containing the new settings.
         * If `null`, settings will be unchanged by this transaction.
         * */
        private @Nullable File settingsFile;

        /** Paths to the temporary files containing the objects that will be added/updated by this transaction. */
        private List<String> objectFilePaths = new ArrayList<>();

        /**
         * Identifiers of objects that will be deleted by this transaction.
         *
         * **Warning:** Deleted objects have higher precedence than added/updated objects.
         */
        private Set<String> deletedObjectIDs = new HashSet<>();

        /** Whether the index should be cleared before executing this transaction. */
        private boolean shouldClearIndex = false;

        /** Temporary directory for this transaction. */
        private final File tmpDir;

        /** Temporary in-memory cache for objects. */
        private List<JSONObject> tmpObjects = new ArrayList<>();

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

        /** Maximum number of objects to keep in memory before flushing to disk. */
        private int maxObjectsInMemory = 100;

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

        public WriteTransaction() {
            this.id = nextTransactionSeqNo();
            this.tmpDir = new File(getClient().getTempDir(), UUID.randomUUID().toString());

            // Create temporary directory.
            tmpDir.mkdirs();
        }

        @Override
        public void finalize() {
            if (!finished) {
                Log.w("AlgoliaSearch", String.format("Transaction %s was never committed nor rolled back", this));
                doRollback();
            }
        }

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

        @Override
        public @NonNull String toString() {
            return String.format("%s{index: %s, id: %d}", this.getClass().getSimpleName(), OfflineIndex.this, id);
        }

        /**
         * Get this transaction's ID.
         * @return This transaction's ID.
         */
        public int getId() {
            return id;
        }

        /**
         * Test whether this transaction is finished.
         * @return Whether this transaction is finished.
         */
        public boolean isFinished() {
            return finished;
        }

        // Populating
        // ----------

        /**
         * Save an object.
         *
         *  @param object Object to save. Must contain an `objectID` attribute.
         *  @param completionHandler Completion handler to be notified of the request's outcome.
         *  @return A cancellable operation.
         */
        public Request saveObjectAsync(final @NonNull JSONObject object, CompletionHandler completionHandler) {
            return getClient().new AsyncTaskRequest(completionHandler, getClient().transactionExecutorService) {
                @NonNull
                @Override
                protected JSONObject run() throws AlgoliaException {
                    try {
                        String objectID = object.getString("objectID");
                        saveObjectSync(object);
                        return new JSONObject()
                                .put("objectID", objectID)
                                .put("taskID", id);
                    } catch (JSONException e) {
                        throw new AlgoliaException("Object missing `objectID` attribute", e);
                    }
                }
            }.start();
        }

        /**
         * Save an object (synchronously).
         *
         * @param object Object to save. Must contain an `objectID` attribute.
         */
        public void saveObjectSync(@NonNull JSONObject object) throws AlgoliaException {
            assertNotMainThread();
            synchronized(this) {
                if (finished) throw new IllegalStateException();
                tmpObjects.add(object);
                flushObjectsToDisk(false);
            }
        }

        /**
         * Save multiple objects.
         *
         *  @param objects Objects to save. Each one must contain an `objectID` attribute.
         *  @param completionHandler Completion handler to be notified of the request's outcome.
         *  @return A cancellable operation.
         */
        public Request saveObjectsAsync(final @NonNull JSONArray objects, CompletionHandler completionHandler) {
            return getClient().new AsyncTaskRequest(completionHandler, getClient().transactionExecutorService) {
                @NonNull
                @Override
                protected JSONObject run() throws AlgoliaException {
                    try {
                        List<String> objectIDs = new ArrayList<>();
                        for (int i = 0; i < objects.length(); ++i) {
                            objectIDs.add(objects.getJSONObject(i).getString("objectID"));
                        }
                        saveObjectsSync(objects);
                        return new JSONObject()
                                .put("objectIDs", new JSONArray(objectIDs))
                                .put("taskID", id);
                    } catch (JSONException e) {
                        throw new AlgoliaException("Object missing `objectID` attribute", e);
                    }
                }
            }.start();
        }

        /**
         * Save multiple objects (synchronously).
         *
         * @param objects Objects to save. Each one must contain an `objectID` attribute.
         */
        public void saveObjectsSync(@NonNull JSONArray objects) throws AlgoliaException {
            assertNotMainThread();
            try {
                synchronized(this) {
                    if (finished) throw new IllegalStateException();
                    for (int i = 0; i < objects.length(); ++i) {
                        JSONObject object = objects.getJSONObject(i);
                        tmpObjects.add(object);
                    }
                    flushObjectsToDisk(false);
                }
            } catch (JSONException e) {
                throw new AlgoliaException("Array must contain only objects", e);
            }
        }

        /**
         * Delete an object.
         *
         *  @param objectID Identifier of object to delete.
         *  @param completionHandler Completion handler to be notified of the request's outcome.
         *  @return A cancellable operation.
         */
        public Request deleteObjectAsync(final @NonNull String objectID, CompletionHandler completionHandler) {
            return getClient().new AsyncTaskRequest(completionHandler, getClient().transactionExecutorService) {
                @NonNull
                @Override
                protected JSONObject run() throws AlgoliaException {
                    deleteObjectSync(objectID);
                    return new JSONObject();
                }
            }.start();
        }

        /**
         * Delete an object (synchronously).
         *
         * @param objectID Identifier of the object to delete.
         */
        public void deleteObjectSync(@NonNull String objectID) throws AlgoliaException {
            assertNotMainThread();
            synchronized(this) {
                if (finished) throw new IllegalStateException();
                deletedObjectIDs.add(objectID);
            }
        }

        /**
         * Delete multiple objects.
         *
         *  @param objectIDs Identifiers of objects to delete.
         *  @param completionHandler Completion handler to be notified of the request's outcome.
         *  @return A cancellable operation.
         */
        public Request deleteObjectsAsync(final Collection<String> objectIDs, CompletionHandler completionHandler) {
            return getClient().new AsyncTaskRequest(completionHandler, getClient().transactionExecutorService) {
                @NonNull
                @Override
                protected JSONObject run() throws AlgoliaException {
                    try {
                        deleteObjectsSync(objectIDs);
                        return new JSONObject()
                                .put("objectIDs", new JSONArray(objectIDs))
                                .put("taskID", id);
                    } catch (JSONException e) {
                        throw new RuntimeException(e); // should never happen
                    }
                }
            }.start();
        }

        /**
         * Delete multiple objects (synchronously).
         *
         * @param objectIDs Identifiers of the objects to delete.
         */
        public void deleteObjectsSync(@NonNull Collection<String> objectIDs) throws AlgoliaException {
            assertNotMainThread();
            synchronized(this) {
                if (finished) throw new IllegalStateException();
                deletedObjectIDs.addAll(objectIDs);
            }
        }

        /**
         * Set the index settings.
         *
         * Please refer to our [API documentation](https://www.algolia.com/doc/swift#index-settings) for the list of
         * supported settings.
         *
         *  @param settings New settings.
         *  @param completionHandler Completion handler to be notified of the request's outcome.
         *  @return A cancellable operation.
         */
        public Request setSettingsAsync(final @NonNull JSONObject settings, CompletionHandler completionHandler) {
            return getClient().new AsyncTaskRequest(completionHandler, getClient().transactionExecutorService) {
                @NonNull
                @Override
                protected JSONObject run() throws AlgoliaException {
                    try {
                        setSettingsSync(settings);
                        return new JSONObject()
                                .put("taskID", id);
                    } catch (JSONException e) {
                        throw new RuntimeException(e); // should never happen
                    }
                }
            }.start();
        }

        /**
         * Set the index settings (synchronously).
         *
         * Please refer to our [API documentation](https://www.algolia.com/doc/swift#index-settings) for the list of
         * supported settings.
         *
         * @param settings New settings.
         */
        public void setSettingsSync(@NonNull  JSONObject settings) throws AlgoliaException {
            assertNotMainThread();
            synchronized(this) {
                if (finished) throw new IllegalStateException();
                settingsFile = writeTmpJSONFile(settings);
            }
        }

        /**
         * Delete the index content without removing settings.
         *
         *  @param completionHandler Completion handler to be notified of the request's outcome.
         *  @return A cancellable operation.
         */
        public Request clearIndexAsync(CompletionHandler completionHandler) {
            return getClient().new AsyncTaskRequest(completionHandler, getClient().transactionExecutorService) {
                @NonNull
                @Override
                protected JSONObject run() throws AlgoliaException {
                    try {
                        clearIndexSync();
                        return new JSONObject()
                                .put("taskID", id);
                    } catch (JSONException e) {
                        throw new RuntimeException(e); // should never happen
                    }
                }
            }.start();
        }

        /**
         * Delete the index content without removing settings.
         */
        public void clearIndexSync() throws AlgoliaException {
            assertNotMainThread();
            synchronized(this) {
                if (finished) throw new IllegalStateException();
                shouldClearIndex = true;
                deletedObjectIDs.clear();
                objectFilePaths.clear();
            }
        }

        // Completion
        // ----------

        /**
         * Commit the transaction.
         *
         * **Warning:** Cancelling the returned operation does **not** roll back the transaction. The operation is returned
         *   for lifetime management purposes only.
         *
         * @param completionHandler Completion handler to be notified of the transaction's outcome.
         * @return A cancellable operation (see warning for important caveat).
         */
        public Request commitAsync(@NonNull CompletionHandler completionHandler) {
            return getClient().new AsyncTaskRequest(completionHandler, getClient().localBuildExecutorService) {
                @NonNull
                @Override
                protected JSONObject run() throws AlgoliaException {
                    doCommit();
                    return new JSONObject();
                }
            }.start();
        }

        /**
         * Commit the transaction (synchronously).
         */
        public void commitSync() throws AlgoliaException {
            assertNotMainThread();
            doCommit();
        }

        private void doCommit() throws AlgoliaException {
            // Serialize calls with respect to this transaction.
            synchronized (this) {
                if (finished) throw new IllegalStateException();
                try {
                    flushObjectsToDisk(true);
                    Response result = OfflineIndex.this.localIndex.build(
                        settingsFile != null ? settingsFile.getAbsolutePath() : null,
                        objectFilePaths.toArray(new String[objectFilePaths.size()]),
                        shouldClearIndex,
                        deletedObjectIDs.toArray(new String[deletedObjectIDs.size()])
                    );
                    OfflineClient.parseSearchResults(result);
                } finally {
                    finished = true;
                }
            }
        }

        /**
         * Roll back the current write transaction.
         *
         *  **Warning:** Cancelling the returned operation does **not** cancel the rollback. The operation is returned
         *   for lifetime management purposes only.
         *
         *  @param completionHandler Completion handler to be notified of the transaction's outcome.
         *  @return A cancellable operation (see warning for important caveat).
         */
        public Request rollbackAsync(CompletionHandler completionHandler) {
            return getClient().new AsyncTaskRequest(completionHandler, getClient().transactionExecutorService) {
                @NonNull
                @Override
                protected JSONObject run() throws AlgoliaException {
                    rollbackSync();
                    return new JSONObject();
                }
            }.start();
        }

        /**
         * Rollback the transaction.
         * The index will be left untouched.
         */
        public void rollbackSync() {
            assertNotMainThread();
            doRollback();
        }

        private void doRollback() {
            synchronized(this) {
                if (finished) throw new IllegalStateException();
                FileUtils.deleteRecursive(tmpDir);
                finished = true;
            }
        }

        // Utils
        // -----

        private void flushObjectsToDisk(boolean force) throws AlgoliaException {
            if (force || tmpObjects.size() >= maxObjectsInMemory) {
                File tmpFile = writeTmpJSONFile(tmpObjects);
                objectFilePaths.add(tmpFile.getAbsolutePath());
                tmpObjects.clear();
            }
        }
    }

    /**
     * Create a new write transaction.
     */
    public @NonNull WriteTransaction newTransaction() {
        return new WriteTransaction();
    }

    /**
     * Test if this index has offline data on disk.
     *
     * **Warning:** This method is synchronous! It will block until completion.
     *
     * @return `true` if data exists on disk for this index, `false` otherwise.
     */
    public boolean hasOfflineData() {
        return localIndex.exists();
    }

    // ----------------------------------------------------------------------
    // Manual build
    // ----------------------------------------------------------------------

    /**
     * Build the index from local data stored on the filesystem.
     *
     * @param settingsFile Absolute path to the file containing the index settings, in JSON format.
     * @param objectFiles Absolute path(s) to the file(s) containing the objects. Each file must contain an array of
     *                    objects, in JSON format.
     * @param completionHandler Optional completion handler to be notified of the build's outcome.
     * @return A cancellable request.
     *
     * **Note:** Cancelling the request does *not* cancel the build; it merely prevents the completion handler from
     * being called.
     */
    public Request buildFromFiles(@NonNull final File settingsFile, @NonNull final File[] objectFiles, @Nullable CompletionHandler completionHandler) {
        return getClient().new AsyncTaskRequest(completionHandler, getClient().localBuildExecutorService) {
            @NonNull
            @Override
            protected JSONObject run() throws AlgoliaException {
                return _build(settingsFile, objectFiles);
            }
        }.start();
    }

    public Request buildFromFiles(@NonNull final File settingsFile, @NonNull final File... objectFiles) {
        return buildFromFiles(settingsFile, objectFiles, null);
    }

    /**
     * Build the index from local data stored in raw resources.
     *
     * @param resources A {@link Resources} instance to read resources from.
     * @param settingsResId Resource identifier of the index settings, in JSON format.
     * @param objectsResIds Resource identifiers of the various objects files. Each file must contain an array of
     *                    objects, in JSON format.
     * @param completionHandler Optional completion handler to be notified of the build's outcome.
     * @return A cancellable request.
     *
     * **Note:** Cancelling the request does *not* cancel the build; it merely prevents the completion handler from
     * being called.
     */
    public Request buildFromRawResources(@NonNull final Resources resources, @NonNull final int settingsResId, @NonNull final int[] objectsResIds, @Nullable CompletionHandler completionHandler) {
        return getClient().new AsyncTaskRequest(completionHandler, getClient().localBuildExecutorService) {
            @NonNull
            @Override
            protected JSONObject run() throws AlgoliaException {
                return _buildFromRawResources(resources, settingsResId, objectsResIds);
            }
        }.start();
    }

    public Request buildFromRawResources(@NonNull final Resources resources, @NonNull final int settingsResId, @NonNull final int... objectsResIds) {
        return buildFromRawResources(resources, settingsResId, objectsResIds, null);
    }

    private JSONObject _buildFromRawResources(@NonNull final Resources resources, @NonNull final int settingsResId, @NonNull final int... objectsResIds) throws AlgoliaException {
        // Save resources to independent files on disk.
        File tmpDir = new File(getClient().getTempDir(), UUID.randomUUID().toString());
        try {
            tmpDir.mkdirs();
            // Settings.
            File settingsFile = new File(tmpDir, "settings.json");
            FileUtils.writeFile(settingsFile, resources.openRawResource(settingsResId));
            // Objects.
            File[] objectFiles = new File[objectsResIds.length];
            for (int i = 0; i < objectsResIds.length; ++i) {
                objectFiles[i] = new File(tmpDir, "objects#" + Integer.toString(objectsResIds[i]) + ".json");
                FileUtils.writeFile(objectFiles[i], resources.openRawResource(objectsResIds[i]));
            }
            // Build the index.
            return _build(settingsFile, objectFiles);
        } catch (IOException e) {
            throw new AlgoliaException("Failed to write build resources to disk", e);
        } finally {
            // Delete temporary files.
            FileUtils.deleteRecursive(tmpDir);
        }
    }

    private JSONObject _build(@NonNull File settingsFile, @NonNull File... objectFiles) throws AlgoliaException {
        AlgoliaException error = null;
        String[] objectFilePaths = new String[objectFiles.length];
        for (int i = 0; i < objectFiles.length; ++i) {
            objectFilePaths[i] = objectFiles[i].getAbsolutePath();
        }
        final Response result = localIndex.build(settingsFile.getAbsolutePath(), objectFilePaths, true /* clearIndex */, null /* deletedObjectIDs */);
        return OfflineClient.parseSearchResults(result);
    }

    // ----------------------------------------------------------------------
    // Helpers
    // ----------------------------------------------------------------------

    /**
     * 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 List<String> disjunctiveFacets, @NonNull final Map<String, List<String>> refinements, @NonNull final CompletionHandler completionHandler) {
        return new DisjunctiveFaceting() {
            @Override
            protected Request multipleQueriesAsync(@NonNull Collection<Query> queries, @NonNull CompletionHandler completionHandler) {
                return OfflineIndex.this.multipleQueriesAsync(new ArrayList(queries), null, completionHandler);
            }
        }.searchDisjunctiveFacetingAsync(query, disjunctiveFacets, refinements, completionHandler);
    }

    /**
     * Delete 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 deleteByQueryAsync(@NonNull Query query, CompletionHandler completionHandler) {
        final WriteTransaction transaction = newTransaction();
        final Query queryCopy = new Query(query);
        return getClient().new AsyncTaskRequest(completionHandler, getClient().transactionExecutorService) {
            @NonNull
            @Override
            protected JSONObject run() throws AlgoliaException {
                try {
                    Collection<String> deletedObjectIDs = deleteByQuerySync(queryCopy, transaction);
                    transaction.commitSync();
                    return new JSONObject()
                        .put("objectIDs", new JSONArray(deletedObjectIDs))
                        .put("updatedAt", DateUtils.iso8601String(new Date()))
                        .put("taskID", transaction.id);
                } catch (JSONException e) {
                    throw new RuntimeException(e); // should never happen
                }
            }
        }.start();
    }

    private Collection<String> deleteByQuerySync(@NonNull Query query, @NonNull WriteTransaction transaction) throws AlgoliaException {
        try {
            final Query browseQuery = new Query(query);
            browseQuery.setAttributesToRetrieve("objectID");
            final String queryParameters = browseQuery.build();

            // Gather object IDs to delete.
            List<String> objectIDsToDelete = new ArrayList<>();
            boolean hasMore = true;
            while (hasMore) {
                JSONObject content = OfflineClient.parseSearchResults(localIndex.browse(queryParameters));
                JSONArray hits = content.getJSONArray("hits");

                // Retrieve object IDs.
                for (int i = 0; i < hits.length(); ++i) {
                    JSONObject hit = hits.getJSONObject(i);
                    String objectID = hit.getString("objectID");
                    objectIDsToDelete.add(objectID);
                }
                hasMore = content.optString("cursor", null) != null;
            }

            // Delete objects.
            transaction.deleteObjectsSync(objectIDsToDelete);
            return objectIDsToDelete;
        } catch (JSONException e) {
            throw new AlgoliaException("Invalid JSON results", e);
        }
    }

    // ----------------------------------------------------------------------
    // Utils
    // ----------------------------------------------------------------------

    /**
     * Create a copy of a JSON object with a specific <code>objectID</code> attribute.
     *
     * @param object The object to copy.
     * @param objectID The ID for the new object.
     * @return A new JSON object with the specified object ID.
     */
    private static JSONObject objectWithID(@NonNull JSONObject object, @NonNull String objectID) {
        try {
            // WARNING: Could not find a better way to clone the object.
            JSONObject patchedObject = new JSONObject(object.toString());
            patchedObject.put("objectID", objectID);
            return patchedObject;
        }
        catch (JSONException e) {
            throw new RuntimeException(e); // should never happen
        }
    }

    /**
     * Write a temporary file containing a JSON object.
     *
     * @param object JSON object to write.
     * @return Path to the created file.
     * @throws AlgoliaException
     */
    private @NonNull File writeTmpJSONFile(@NonNull JSONObject object) throws AlgoliaException {
        return writeTempFile(object.toString());
    }

    /**
     * Write a temporary file containing JSON objects. The files are written as an array.
     *
     * @param objects JSON objects to write.
     * @return Path to the created file.
     * @throws AlgoliaException
     */
    private File writeTmpJSONFile(@NonNull Collection<JSONObject> objects) throws AlgoliaException {
        // TODO: Maybe split in several files if too big?
        // TODO: Stream writing.
        JSONArray array = new JSONArray();
        for (JSONObject object : objects) {
            array.put(object);
        }
        return writeTempFile(array.toString());
    }

    /**
     * Write a temporary file containing textual data in UTF-8 encoding.
     *
     * @param data Data to write.
     * @return Path to the created file.
     * @throws AlgoliaException
     */
    private File writeTempFile(@NonNull String data) throws AlgoliaException {
        try {
            // Create temporary file.
            File tmpDir = client.getTempDir();
            File tmpFile = File.createTempFile("algolia.", ".json", tmpDir);

            // Write to file.
            Writer writer = new OutputStreamWriter(new FileOutputStream(tmpFile), "UTF-8");
            writer.write(data);
            writer.close();

            return tmpFile;
        } catch (IOException e) {
            throw new AlgoliaException("Could not create temporary file", e);
        }
    }

    private synchronized int nextTransactionSeqNo() {
        transactionSeqNo += 1;
        return transactionSeqNo;
    }

    private void assertNotMainThread() {
        // NOTE: Throwing an exception would be rather extreme, and also causes problems with unit tests, where all
        // threads are unwound onto the main thread. => A log message should be deterrent enough.
        if (Looper.getMainLooper().getThread() == Thread.currentThread()) {
            Log.println(Log.ASSERT, "AlgoliaSearch", "Synchronous methods should not be called from the main thread");
        }
    }
}


/*
 * Copyright (c) 2012-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.content.Context;
import android.os.Handler;
import android.os.Looper;
import android.support.annotation.NonNull;

import com.algolia.search.offline.core.LocalIndex;
import com.algolia.search.offline.core.Response;
import com.algolia.search.offline.core.Sdk;

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

import java.io.File;
import java.io.FileFilter;
import java.io.UnsupportedEncodingException;
import java.lang.ref.WeakReference;
import java.util.Date;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

/**
 * An API client that adds offline features on top of the regular online API client.
 *
 *
NOTE: Requires Algolia's SDK. The {@link #enableOfflineMode(String)} method must be called with a valid license
 * key prior to calling any offline-related method.

 */
public class OfflineClient extends Client
{
    private Context context;
    private File rootDataDir;

    // Threading facilities
    // --------------------
    // Used by the indices to coordinate their execution.
    //
    // NOTE: The build and search queues must be serial to prevent concurrent searches or builds on a given index, but
    // may be distinct because building can be done in parallel with search.
    //
    // NOTE: Although serialization is only strictly needed at the index level, we use global queues as a way to limit
    // resource consumption by the SDK.

    /** Background queue used to build local indices. */
    protected ExecutorService localBuildExecutorService = Executors.newSingleThreadExecutor();

    /** Background queue used to search local indices. */
    protected ExecutorService localSearchExecutorService = Executors.newSingleThreadExecutor();

    /** Background queue used to run transaction bodies (but not the build). */
    protected ExecutorService transactionExecutorService = Executors.newSingleThreadExecutor();

    /**
     * Handler used to run mixed online/offline requests.
     * NOTE: We need a `Handler` instead of an `ExecutorService` because we need to schedule delayed calls.
     */
    protected Handler mixedRequestHandler = new Handler(Looper.getMainLooper());

    /**
     * Construct a new offline-enabled API client.
     *
     * @param context An Android context.
     * @param applicationID See {@link Client}.
     * @param apiKey See {@link Client}.
     */
    public OfflineClient(@NonNull Context context, @NonNull String applicationID, @NonNull String apiKey)
    {
        this(context, applicationID, apiKey, null, null);
    }

    /**
     * Construct a new offline-enabled API client.
     *
     * @param context An Android context.
     * @param applicationID See {@link Client}.
     * @param apiKey See {@link Client}.
     * @param dataDir Path to the directory where the local data will be stored. If null, the default directory will
     *                be used. See {@link #getDefaultDataDir()}.
     */
    public OfflineClient(@NonNull Context context, @NonNull String applicationID, @NonNull String apiKey, File dataDir)
    {
        this(context, applicationID, apiKey, dataDir, null);
    }

    /**
     * Construct a new offline-enabled API client.
     *
     * @param context An Android context.
     * @param applicationID See {@link Client}.
     * @param apiKey See {@link Client}.
     * @param dataDir Path to the directory where the local data will be stored. If null, the default directory will
     *                be used. See {@link #getDefaultDataDir()}.
     * @param hosts See {@link Client}.
     */
    public OfflineClient(@NonNull Context context, @NonNull String applicationID, @NonNull String apiKey, File dataDir, String[] hosts)
    {
        super(applicationID, apiKey, hosts);
        this.context = context;
        if (dataDir != null) {
            this.rootDataDir = dataDir;
        } else {
            this.rootDataDir = getDefaultDataDir();
        }
        this.addUserAgent(new LibraryVersion("algoliasearch-offline-core-android", Sdk.getInstance().getVersionString()));
    }

    /**
     * Create a new index. Although this will always be an instance of {@link MirroredIndex}, mirroring is deactivated
     * by default.
     *
     * @param indexName the name of index
     * @return The newly created index.
     *
     * @deprecated You should now use {@link #getIndex(String)}, which re-uses instances with the same name.
     */
    @Override
    public MirroredIndex initIndex(@NonNull String indexName)
    {
        return new MirroredIndex(this, indexName);
    }

    /**
     * Obtain a mirrored index. Although this will always be an instance of {@link MirroredIndex}, mirroring is
     * deactivated by default.
     *
     * @param indexName The name of the index.
     * @return A proxy to the specified index.
     *
     * **Warning:** The name should not overlap with any `OfflineIndex`. See {@link #getOfflineIndex(String)}.
     */
    @Override
    public @NonNull MirroredIndex getIndex(@NonNull String indexName) {
        MirroredIndex index = null;
        WeakReference<Object> existingIndex = indices.get(indexName);
        if (existingIndex != null) {
            index = (MirroredIndex)existingIndex.get();
        }
        if (index == null) {
            index = new MirroredIndex(this, indexName);
            indices.put(indexName, new WeakReference<Object>(index));
        }
        return index;
    }

    /**
     * Obtain a purely offline index.
     *
     * @param indexName The name of the index.
     * @return A proxy to the specified index.
     *
     * **Warning:** The name should not overlap with any `MirroredIndex`. See {@link #getIndex(String)}.
     */
    public OfflineIndex getOfflineIndex(@NonNull String indexName) {
        OfflineIndex index = null;
        WeakReference<Object> existingIndex = indices.get(indexName);
        if (existingIndex != null) {
            index = (OfflineIndex)existingIndex.get();
        }
        if (index == null) {
            index = new OfflineIndex(this, indexName);
            indices.put(indexName, new WeakReference<Object>(index));
        }
        return index;
    }

    /**
     * Get the path to directory where the local data is stored.
     */
    public @NonNull File getRootDataDir()
    {
        return rootDataDir;
    }

    /**
     * Get the path to the temporary directory used by this client.
     *
     * @return The path to the temporary directory.
     */
    protected @NonNull File getTempDir() {
        return context.getCacheDir();
    }

    /**
     * Enable the offline mode.
     * @param licenseData License for Algolia's SDK.
     */
    public void enableOfflineMode(@NonNull String licenseData) {
        // Init the SDK.
        Sdk.getInstance().init(context, licenseData);
        // TODO: Report any error.
    }

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

    /**
     * Get the default data directory.
     * This is an "algolia" subdirectory inside the application's files directory.
     *
     * @return The default data directory.
     */
    public File getDefaultDataDir() {
        return new File(context.getFilesDir(), "algolia");
    }

    public @NonNull Context getContext() {
        return context;
    }

    /**
     * Get the data directory for the current application ID.
     *
     * @return The data directory for the current application ID.
     */
    private @NonNull File getAppDir() {
        return new File(getRootDataDir(), getApplicationID());
    }

    /**
     * Get the data directory for the index with a specified name.
     *
     * @param name The index's name.
     * @return The data directory for the index.
     */
    protected @NonNull File getIndexDir(@NonNull String name) {
        return new File(getAppDir(), name);
    }

    // ----------------------------------------------------------------------
    // Operations
    // ----------------------------------------------------------------------

    /**
     * Test if an index has offline data on disk.
     *
     * **Note:** This applies both to {@link MirroredIndex} and {@link OfflineIndex} instances.
     *
     * **Warning:** This method is synchronous!
     *
     * @param name The index's name.
     * @return `true` if data exists on disk for this index, `false` otherwise.
     */
    public boolean hasOfflineData(@NonNull String name) {
        // TODO: Suboptimal; we should be able to test existence without instantiating a `LocalIndex`.
        return new LocalIndex(getRootDataDir().getAbsolutePath(), getApplicationID(), name).exists();
    }

    /**
     * List existing offline indices.
     * Only indices that *actually exist* on disk are listed. If an instance was created but never synced or written
     * to, it will not appear in the list.
     *
     * **Note:** This applies both to {@link MirroredIndex} and {@link OfflineIndex} instances.
     *
     * @param completionHandler The listener that will be notified of the request's outcome.
     * @return A cancellable request.
     */
    public Request listIndexesOfflineAsync(@NonNull CompletionHandler completionHandler) {
        return new AsyncTaskRequest(completionHandler) {
            @NonNull
            @Override
            protected JSONObject run() throws AlgoliaException {
                return listIndexesOfflineSync();
            }
        }.start();
    }

    /**
     * List existing offline indices.
     *
     * **Note:** This applies both to {@link MirroredIndex} and {@link OfflineIndex} instances. Only indices that
     * *actually exist* on disk are listed. If an instance was created but never synced or written to, it will not
     * appear in the list.
     *
     * @return A JSON object with an `items` attribute containing the indices details as JSON objects.
     */
    private JSONObject listIndexesOfflineSync() throws AlgoliaException {
        try {
            final String rootDataPath = getRootDataDir().getAbsolutePath();
            final File appDir = getAppDir();
            final File[] directories = appDir.listFiles(new FileFilter() {
                @Override
                public boolean accept(File pathname) {
                    return pathname.isDirectory();
                }
            });
            JSONObject response = new JSONObject();
            JSONArray items = new JSONArray();
            if (directories != null) {
                for (File directory : directories) {
                    final String name = directory.getName();
                    if (hasOfflineData(name)) {
                        items.put(new JSONObject()
                            .put("name", name)
                        );
                        // TODO: Do we need other data as in the online API?
                    }
                }
            }
            response.put("items", items);
            return response;
        } catch (JSONException e) {
            throw new RuntimeException(e); // should never happen
        }
    }

    /**
     * Delete an offline index.
     * This deletes the data on disk. If the index does not exist, this method does nothing.
     *
     * **Note:** This applies both to {@link MirroredIndex} and {@link OfflineIndex} instances.
     *
     * @param indexName Name of index to delete.
     * @return A JSON object.
     */
    public Request deleteIndexOfflineAsync(final @NonNull String indexName, CompletionHandler completionHandler) {
        return new AsyncTaskRequest(completionHandler, localBuildExecutorService) {
            @NonNull
            @Override
            protected JSONObject run() throws AlgoliaException {
                return deleteIndexOfflineSync(indexName);
            }
        }.start();
    }

    /**
     * Delete an offline index.
     * This deletes the data on disk. If the index does not exist, this method does nothing.
     *
     * **Note:** This applies both to {@link MirroredIndex} and {@link OfflineIndex} instances.
     *
     * @param indexName Name of index to delete.
     * @return A JSON response.
     */
    private JSONObject deleteIndexOfflineSync(final @NonNull String indexName) throws AlgoliaException {
        try {
            FileUtils.deleteRecursive(getIndexDir(indexName));
            return new JSONObject()
                .put("deletedAt", DateUtils.iso8601String(new Date()));
        } catch (JSONException e) {
            throw new RuntimeException(e); // should never happen
        }
    }

    /**
     * Move an existing offline index.
     *
     * **Warning:** This will overwrite the destination index if it exists.
     *
     * **Note:** This applies both to {@link MirroredIndex} and {@link OfflineIndex} instances.
     *
     * @param srcIndexName Name of index to move.
     * @param dstIndexName The new index name.
     * @param completionHandler The listener that will be notified of the request's outcome.
     * @return A cancellable request.
     */
    public Request moveIndexOfflineAsync(final @NonNull String srcIndexName, final @NonNull String dstIndexName, CompletionHandler completionHandler) {
        return new AsyncTaskRequest(completionHandler, localBuildExecutorService) {
            @NonNull
            @Override
            protected JSONObject run() throws AlgoliaException {
                return moveIndexOfflineSync(srcIndexName, dstIndexName);
            }
        }.start();
    }

    /**
     * Move an existing offline index.
     *
     * **Warning:** This will overwrite the destination index if it exists.
     *
     * **Note:** This applies both to {@link MirroredIndex} and {@link OfflineIndex} instances.
     *
     * @param srcIndexName Name of index to move.
     * @param dstIndexName The new index name.
     * @return A JSON response.
     */
    private JSONObject moveIndexOfflineSync(final @NonNull String srcIndexName, final @NonNull String dstIndexName) throws AlgoliaException {
        try {
            final File srcDir = getIndexDir(srcIndexName);
            final File dstDir = getIndexDir(dstIndexName);
            if (dstDir.exists()) {
                FileUtils.deleteRecursive(dstDir);
            }
            if (srcDir.renameTo(dstDir)) {
                return new JSONObject()
                    .put("updatedAt", DateUtils.iso8601String(new Date()));
            } else {
                throw new AlgoliaException("Could not move index");
            }
        } catch (JSONException e) {
            throw new RuntimeException(e); // should never happen
        }
    }

    // NOTE: Copy not supported because it would be too resource-intensive.

    // ----------------------------------------------------------------------
    // Utils
    // ----------------------------------------------------------------------

    static protected JSONObject parseSearchResults(Response searchResults) throws AlgoliaException {
        try {
            if (searchResults.getStatusCode() == 200) {
                if (searchResults.getData() != null) {
                    String jsonString = new String(searchResults.getData(), "UTF-8");
                    return new JSONObject(jsonString);
                } else { // may happen when building: no output
                    return new JSONObject();
                }
            }
            else {
                throw new AlgoliaException(searchResults.getErrorMessage(), searchResults.getStatusCode());
            }
        }
        catch (JSONException | UnsupportedEncodingException e) {
            throw new AlgoliaException("Offline Core returned invalid JSON", e);
        }
    }
}


/*
 * Copyright (c) 2012-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 java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;

/**
 * Various filesystem-related utilities.
 */
class FileUtils
{
    /**
     * Delete a file or directory, recursively deleting any descendant files/directories if it's a directory.
     *
     * @param item The item to delete.
     * @return true if success, false if error.
     */
    public static boolean deleteRecursive(@NonNull File item)
    {
        boolean ok = true;
        if (item.isDirectory()) {
            for (File child : item.listFiles()) {
                ok = ok && deleteRecursive(child);
            }
        }
        ok = ok && item.delete();
        return ok;
    }

    /**
     * Write a stream of bytes to a file.
     *
     * @param destinationFile The file to be written to. The parent directory must exist. If the file already exists,
     *                        it will be overwritten.
     * @param content The bytes to write.
     * @throws IOException if anything goes wrong.
     */
    public static void writeFile(@NonNull File destinationFile, @NonNull InputStream content) throws IOException {
        byte[] buffer = new byte[64 * 1024]; // 64 kB buffer
        FileOutputStream outputStream = new FileOutputStream(destinationFile);
        try {
            int bytesRead;
            while ((bytesRead = content.read(buffer)) >= 0) {
                outputStream.write(buffer, 0, bytesRead);
            }
        } finally {
            content.close();
            outputStream.close();
        }
    }
}


/*
 * Copyright (c) 2012-2017 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 java.util.HashMap;
import java.util.Map;

/**
 * Per-request options.
 * This class allows specifying options at the request level, overriding default options at the {@link Client} level.
 *
 * NOTE: These are reserved for advanced use cases. In most situations, they shouldn't be needed.
 */
public class RequestOptions {
    // Low-level storage
    // -----------------

    /** HTTP headers, as untyped values. */
    @NonNull
    Map<String, String> headers = new HashMap<>();

    /**
     * URL parameters, as untyped values.
     * These will go into the query string part of the URL (after the question mark).
     */
    @NonNull
    Map<String, String> urlParameters= new HashMap<>();

    /**
     * Set a HTTP header (untyped version).
     * Whenever possible, you should use a typed accessor.
     *
     * @param name Name of the header.
     * @param value Value of the header, or `null` to remove the header.
     */
    public RequestOptions setHeader(@NonNull String name, @Nullable String value) {
        if (value == null) {
            headers.remove(name);
        } else {
            headers.put(name, value);
        }
        return this;
    }

    /**
     * Get the value of a HTTP header.
     *
     * @param name Name of the header.
     * @return Value of the header, or `null` if it does not exist.
     */
    public String getHeader(@NonNull String name) {
        return headers.get(name);
    }

    /**
     * Set a URL parameter (untyped version).
     * Whenever possible, you should use a typed accessor.
     *
     * @param name Name of the parameter.
     * @param value Value of the parameter, or `null` to remove it.
     */
    public RequestOptions setUrlParameter(@NonNull String name, @Nullable String value) {
        if (value == null) {
            urlParameters.remove(name);
        } else {
            urlParameters.put(name, value);
        }
        return this;
    }

    /**
     * Get the value of a URL parameter.
     *
     * @param name Name of the parameter.
     * @return Value of the parameter, or `null` if it does not exist.
     */
    public String getUrlParameter(@NonNull String name) {
        return urlParameters.get(name);
    }

    // Debug
    // -----

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

    // Comparison
    // ----------

    @Override
    public boolean equals(Object obj) {
        if (!(obj instanceof RequestOptions)) return false;
        RequestOptions another = (RequestOptions)obj;
        return this.headers.equals(another.headers);
    }

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

    /**
     * Construct empty request options.
     */
    public RequestOptions() {
    }
}


/*
 * 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.os.Build;
import android.os.Handler;
import android.os.Looper;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;

import com.algolia.search.saas.android.BuildConfig;
import com.algolia.search.saas.helpers.HandlerExecutor;

import org.json.JSONException;
import org.json.JSONObject;
import org.json.JSONTokener;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.UnsupportedEncodingException;
import java.lang.ref.WeakReference;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Executor;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.zip.GZIPInputStream;

/**
 * An abstract API client.
 */
public abstract class AbstractClient {
    // ----------------------------------------------------------------------
    // Types
    // ----------------------------------------------------------------------

    /***
     * A version of a software library.
     * Used to construct the User-Agent header.
     */
    public static class LibraryVersion {
        public final @NonNull String name;
        public final @NonNull String version;

        public LibraryVersion(@NonNull String name, @NonNull String version) {
            this.name = name;
            this.version = version;
        }

        @Override
        public boolean equals(Object object) {
            if (!(object instanceof LibraryVersion)) {
                return false;
            }
            LibraryVersion other = (LibraryVersion) object;
            return this.name.equals(other.name) && this.version.equals(other.version);
        }

        @Override
        public int hashCode() {
            return name.hashCode() ^ version.hashCode();
        }
    }

    private static class HostStatus {
        boolean isUp = true;
        long lastTryTimestamp;

        HostStatus(boolean isUp) {
            this.isUp = isUp;
            lastTryTimestamp = new Date().getTime();
        }
    }

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

    /** This library's version. */
    private final static String version = BuildConfig.VERSION_NAME;

    /** Maximum size for an API key to be sent in the HTTP headers. Bigger keys will go inside the body. */
    private final static int MAX_API_KEY_LENGTH = 500;

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

    /**
     * The user agents as a raw string. This is what is passed in request headers.
     * WARNING: It is stored for efficiency purposes. It should not be modified directly.
     */
    private String userAgentRaw;

    /** The user agents, as a structured list of library versions. */
    private List<LibraryVersion> userAgents = new ArrayList<>();

    /** Connect timeout (ms). */
    private int connectTimeout = 2000;

    /** Default read (receive) timeout (ms). */
    private int readTimeout = 30000;

    /** Read timeout for search requests (ms). */
    private int searchTimeout = 5000;

    /** Delay to wait when a host is down before retrying it (ms). */
    private int hostDownDelay = 5000;

    private final String applicationID;
    private final String apiKey;
    private List<String> readHosts;
    private List<String> writeHosts;
    private HashMap<String, HostStatus> hostStatuses = new HashMap<>();

    /**
     * HTTP headers that will be sent with every request.
     */
    private HashMap<String, String> headers = new HashMap<String, String>();

    /** Thread pool used to run asynchronous requests. */
    protected ExecutorService searchExecutorService = Executors.newFixedThreadPool(4);

    /** Executor used to run completion handlers. By default, runs on the main thread. */
    protected @NonNull
    Executor completionExecutor = new HandlerExecutor(new Handler(Looper.getMainLooper()));

    protected Map<String, WeakReference<Object>> indices = new HashMap<>();

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

    /**
     * Create a new client.
     *
     * @param applicationID [optional] The application ID.
     * @param apiKey        [optional] A valid API key for the service.
     * @param readHosts     List of hosts for read operations.
     * @param writeHosts    List of hosts for write operations.
     */
    protected AbstractClient(@Nullable String applicationID, @Nullable String apiKey, @Nullable String[] readHosts, @Nullable String[] writeHosts) {
        this.applicationID = applicationID;
        this.apiKey = apiKey;
        this.addUserAgent(new LibraryVersion("Algolia for Android", version));
        this.addUserAgent(new LibraryVersion("Android", Build.VERSION.RELEASE));
        if (readHosts != null) {
            setReadHosts(readHosts);
        }
        if (writeHosts != null) {
            setWriteHosts(writeHosts);
        }
    }

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

    public String getApplicationID() {
        return applicationID;
    }

    /**
     * Set an HTTP header that will be sent with every request.
     *
     * @param name  Header name.
     * @param value Value for the header. If null, the header will be removed.
     */
    public void setHeader(@NonNull String name, @Nullable String value) {
        if (value == null) {
            headers.remove(name);
        } else {
            headers.put(name, value);
        }
    }

    /**
     * Get an HTTP header.
     *
     * @param name Header name.
     */
    public String getHeader(@NonNull String name) {
        return headers.get(name);
    }

    public String[] getReadHosts() {
        return readHosts.toArray(new String[readHosts.size()]);
    }

    public void setReadHosts(@NonNull String... hosts) {
        if (hosts.length == 0) {
            throw new IllegalArgumentException("Hosts array cannot be empty");
        }
        readHosts = Arrays.asList(hosts);
    }

    public String[] getWriteHosts() {
        return writeHosts.toArray(new String[writeHosts.size()]);
    }

    public void setWriteHosts(@NonNull String... hosts) {
        if (hosts.length == 0) {
            throw new IllegalArgumentException("Hosts array cannot be empty");
        }
        writeHosts = Arrays.asList(hosts);
    }

    /**
     * Set read and write hosts to the same value (convenience method).
     *
     * @param hosts New hosts. Must not be empty.
     */
    public void setHosts(@NonNull String... hosts) {
        setReadHosts(hosts);
        setWriteHosts(hosts);
    }

    /**
     * Get the connection timeout.
     *
     * @return The connection timeout (ms).
     */
    public int getConnectTimeout() {
        return connectTimeout;
    }

    /**
     * Set the connection timeout.
     *
     * @param connectTimeout The new connection timeout (ms).
     */
    public void setConnectTimeout(int connectTimeout) {
        checkTimeout(connectTimeout);
        this.connectTimeout = connectTimeout;
    }

    /**
     * Get the default read timeout.
     *
     * @return The default read timeout (ms).
     */
    public int getReadTimeout() {
        return readTimeout;
    }

    /**
     * Set the default read timeout.
     *
     * @param readTimeout The default read timeout (ms).
     */
    public void setReadTimeout(int readTimeout) {
        checkTimeout(readTimeout);
        this.readTimeout = readTimeout;
    }

    /**
     * Get the read timeout for search requests.
     *
     * @return The read timeout for search requests (ms).
     */
    public int getSearchTimeout() {
        return searchTimeout;
    }

    /**
     * Set the read timeout for search requests.
     *
     * @param searchTimeout The read timeout for search requests (ms).
     */
    public void setSearchTimeout(int searchTimeout) {
        checkTimeout(searchTimeout);
        this.searchTimeout = searchTimeout;
    }

    /**
     * Get the timeout for retrying connection to a down host.
     *
     * @return The delay before connecting again to a down host (ms).
     */
    public int getHostDownDelay() {
        return hostDownDelay;
    }

    /**
     * Set the timeout for retrying connection to a down host.
     *
     * @param hostDownDelay The delay before connecting again to a down host (ms).
     */
    public void setHostDownDelay(int hostDownDelay) {
        checkTimeout(hostDownDelay);
        this.hostDownDelay = hostDownDelay;
    }

    /**
     * Add a software library to the list of user agents.
     *
     * @param userAgent The library to add.
     */
    public void addUserAgent(@NonNull LibraryVersion userAgent) {
        if (!userAgents.contains(userAgent)) {
            userAgents.add(userAgent);
        }
        updateUserAgents();
    }

    /**
     * Remove a software library from the list of user agents.
     *
     * @param userAgent The library to remove.
     */
    public void removeUserAgent(@NonNull LibraryVersion userAgent) {
        userAgents.remove(userAgent);
        updateUserAgents();
    }

    /**
     * Retrieve the list of declared user agents.
     *
     * @return The declared user agents.
     */
    public @NonNull LibraryVersion[] getUserAgents() {
        return userAgents.toArray(new LibraryVersion[userAgents.size()]);
    }

    /**
     * Test whether a user agent is declared.
     *
     * @param userAgent The user agent to look for.
     * @return true if it is declared on this client, false otherwise.
     */
    public boolean hasUserAgent(@NonNull LibraryVersion userAgent) {
        return userAgents.contains(userAgent);
    }

    private void updateUserAgents() {
        StringBuilder s = new StringBuilder();
        for (LibraryVersion userAgent : userAgents) {
            if (s.length() != 0) {
                s.append("; ");
            }
            s.append(userAgent.name);
            s.append(" (");
            s.append(userAgent.version);
            s.append(")");
        }
        userAgentRaw = s.toString();
    }

    private List<String> getReadHostsThatAreUp() {
        return hostsThatAreUp(readHosts);
    }

    private List<String> getWriteHostsThatAreUp() {
        return hostsThatAreUp(writeHosts);
    }

    /**
     * Change the executor on which completion handlers are executed.
     * By default, completion handlers are executed on the main thread.
     *
     * @param completionExecutor The new completion executor to use.
     */
    public void setCompletionExecutor(@NonNull Executor completionExecutor) {
        this.completionExecutor = completionExecutor;
    }

    // ----------------------------------------------------------------------
    // Utilities
    // ----------------------------------------------------------------------

    /**
     * HTTP method.
     */
    private enum Method {
        GET, POST, PUT, DELETE
    }

    protected byte[] getRequestRaw(@NonNull String url, @Nullable Map<String, String> urlParameters, boolean search, @Nullable RequestOptions requestOptions) throws AlgoliaException {
        return _requestRaw(Method.GET, url, urlParameters, /* json: */ null, getReadHostsThatAreUp(), connectTimeout, search ? searchTimeout : readTimeout, requestOptions);
    }

    protected JSONObject getRequest(@NonNull String url, @Nullable Map<String, String> urlParameters, boolean search, @Nullable RequestOptions requestOptions) throws AlgoliaException {
        return _request(Method.GET, url, urlParameters, /* json: */ null, getReadHostsThatAreUp(), connectTimeout, search ? searchTimeout : readTimeout, requestOptions);
    }

    protected JSONObject deleteRequest(@NonNull String url, @Nullable Map<String, String> urlParameters, @Nullable RequestOptions requestOptions) throws AlgoliaException {
        return _request(Method.DELETE, url, urlParameters, /* json: */ null, getWriteHostsThatAreUp(), connectTimeout, readTimeout, requestOptions);
    }

    protected JSONObject postRequest(@NonNull String url, @Nullable Map<String, String> urlParameters, @Nullable String obj, boolean readOperation, @Nullable RequestOptions requestOptions) throws AlgoliaException {
        return _request(Method.POST, url, urlParameters, obj, (readOperation ? getReadHostsThatAreUp() : getWriteHostsThatAreUp()), connectTimeout, (readOperation ? searchTimeout : readTimeout), requestOptions);
    }

    protected byte[] postRequestRaw(@NonNull String url, @Nullable Map<String, String> urlParameters, @Nullable String obj, boolean readOperation, @Nullable RequestOptions requestOptions) throws AlgoliaException {
        return _requestRaw(Method.POST, url, urlParameters, obj, (readOperation ? getReadHostsThatAreUp() : getWriteHostsThatAreUp()), connectTimeout, (readOperation ? searchTimeout : readTimeout), requestOptions);
    }

    protected JSONObject putRequest(@NonNull String url, @Nullable Map<String, String> urlParameters, @NonNull String obj, @Nullable RequestOptions requestOptions) throws AlgoliaException {
        return _request(Method.PUT, url, urlParameters, obj, getWriteHostsThatAreUp(), connectTimeout, readTimeout, requestOptions);
    }

    /**
     * Reads the InputStream as UTF-8
     *
     * @param stream the InputStream to read
     * @return the stream's content as a String
     * @throws IOException if the stream can't be read, decoded as UTF-8 or closed
     */
    private static String _toCharArray(InputStream stream) throws IOException {
        InputStreamReader is = new InputStreamReader(stream, "UTF-8");
        StringBuilder builder = new StringBuilder();
        char[] buf = new char[1000];
        int l = 0;
        while (l >= 0) {
            builder.append(buf, 0, l);
            l = is.read(buf);
        }
        is.close();
        return builder.toString();
    }

    /**
     * Reads the InputStream into a byte array
     *
     * @param stream the InputStream to read
     * @return the stream's content as a byte[]
     * @throws AlgoliaException if the stream can't be read or flushed
     */
    private static byte[] _toByteArray(InputStream stream) throws AlgoliaException {
        ByteArrayOutputStream out = new ByteArrayOutputStream();
        int read;
        byte[] buffer = new byte[1024];

        try {
            while ((read = stream.read(buffer, 0, buffer.length)) != -1) {
                out.write(buffer, 0, read);
            }

            out.flush();
            return out.toByteArray();
        } catch (IOException e) {
            throw new AlgoliaException("Error while reading stream: " + e.getMessage());
        }
    }

    protected static JSONObject _getJSONObject(String input) throws JSONException {
        return new JSONObject(new JSONTokener(input));
    }

    protected static JSONObject _getJSONObject(byte[] array) throws JSONException, UnsupportedEncodingException {
        return new JSONObject(new String(array, "UTF-8"));
    }

    private static JSONObject _getAnswerJSONObject(InputStream istream) throws IOException, JSONException {
        return _getJSONObject(_toCharArray(istream));
    }

    /**
     * Send the query according to parameters and returns its result as a JSONObject
     *
     * @param m              HTTP Method to use
     * @param url            Endpoint URL, *without query string*. The query string is handled by `urlParameters`.
     * @param urlParameters  URL parameters
     * @param json           optional JSON Object to send
     * @param hostsArray     array of hosts to try successively
     * @param connectTimeout maximum wait time to open connection
     * @param readTimeout    maximum time to read data on socket
     * @return a JSONObject containing the resulting data or error
     * @throws AlgoliaException if the request data is not valid json
     */
    private JSONObject _request(@NonNull Method m, @NonNull String url, @Nullable Map<String, String> urlParameters, @Nullable String json, @NonNull List<String> hostsArray, int connectTimeout, int readTimeout, @Nullable RequestOptions requestOptions) throws AlgoliaException {
        try {
            return _getJSONObject(_requestRaw(m, url, urlParameters, json, hostsArray, connectTimeout, readTimeout, requestOptions));
        } catch (JSONException e) {
            throw new AlgoliaException("JSON decode error:" + e.getMessage());
        } catch (UnsupportedEncodingException e) {
            throw new AlgoliaException("UTF-8 decode error:" + e.getMessage());
        }
    }

    /**
     * Send the query according to parameters and returns its result as a JSONObject
     *
     * @param m              HTTP Method to use
     * @param url            Endpoint URL, *without query string*. The query string is handled by `urlParameters`.
     * @param urlParameters  URL parameters
     * @param json           (optional) JSON body
     * @param hostsArray     array of hosts to try successively
     * @param connectTimeout maximum wait time to open connection
     * @param readTimeout    maximum time to read data on socket
     * @return a JSONObject containing the resulting data or error
     * @throws AlgoliaException in case of connection or data handling error
     */
    private byte[] _requestRaw(@NonNull Method m, @NonNull String url, @Nullable Map<String, String> urlParameters, @Nullable String json, @NonNull List<String> hostsArray, int connectTimeout, int readTimeout, @Nullable RequestOptions requestOptions) throws AlgoliaException {
        String requestMethod;
        List<Exception> errors = new ArrayList<>(hostsArray.size());
        // for each host
        for (String host : hostsArray) {
            switch (m) {
                case DELETE:
                    requestMethod = "DELETE";
                    break;
                case GET:
                    requestMethod = "GET";
                    break;
                case POST:
                    requestMethod = "POST";
                    break;
                case PUT:
                    requestMethod = "PUT";
                    break;
                default:
                    throw new IllegalArgumentException("Method " + m + " is not supported");
            }

            InputStream stream = null;
            HttpURLConnection hostConnection = null;
            try {
                // Compute final URL parameters.
                final Map<String, String> parameters = new HashMap<>();
                if (urlParameters != null) {
                    parameters.putAll(urlParameters);
                }
                if (requestOptions != null) {
                    parameters.putAll(requestOptions.urlParameters);
                }

                // Build URL.
                String urlString = "https://" + host + url;
                if (!parameters.isEmpty()) {
                    urlString += "?" + AbstractQuery.build(parameters);
                }
                URL hostURL = new URL(urlString);

                // Open connection.
                hostConnection = (HttpURLConnection) hostURL.openConnection();

                //set timeouts
                hostConnection.setRequestMethod(requestMethod);
                hostConnection.setConnectTimeout(connectTimeout);
                hostConnection.setReadTimeout(readTimeout);

                // Headers
                hostConnection.setRequestProperty("Accept-Encoding", "gzip");
                hostConnection.setRequestProperty("X-Algolia-Application-Id", this.applicationID);
                // If API key is too big, send it in the request's body (if applicable).
                if (this.apiKey != null && this.apiKey.length() > MAX_API_KEY_LENGTH && json != null) {
                    try {
                        final JSONObject body = new JSONObject(json);
                        body.put("apiKey", this.apiKey);
                        json = body.toString();
                    } catch (JSONException e) {
                        throw new AlgoliaException("Failed to patch JSON body");
                    }
                } else {
                    hostConnection.setRequestProperty("X-Algolia-API-Key", this.apiKey);
                }
                // Client-level headers
                for (Map.Entry<String, String> entry : this.headers.entrySet()) {
                    hostConnection.setRequestProperty(entry.getKey(), entry.getValue());
                }
                // Request-level headers
                if (requestOptions != null) {
                    for (Map.Entry<String, String> entry : requestOptions.headers.entrySet()) {
                        hostConnection.setRequestProperty(entry.getKey(), entry.getValue());
                    }
                }

                // set user agent
                hostConnection.setRequestProperty("User-Agent", userAgentRaw);

                // write JSON entity
                if (json != null) {
                    if (!(requestMethod.equals("PUT") || requestMethod.equals("POST"))) {
                        throw new IllegalArgumentException("Method " + m + " cannot enclose entity");
                    }
                    hostConnection.setRequestProperty("Content-type", "application/json; charset=UTF-8");
                    hostConnection.setDoOutput(true);
                    OutputStreamWriter writer = new OutputStreamWriter(hostConnection.getOutputStream(), "UTF-8");
                    writer.write(json);
                    writer.close();
                }

                // read response
                int code = hostConnection.getResponseCode();
                final boolean codeIsError = code / 100 != 2;
                stream = codeIsError ? hostConnection.getErrorStream() : hostConnection.getInputStream();
                // As per the official Java docs (not the Android docs):
                // - `getErrorStream()` may return null => we have to handle this case.
                //   See <https://docs.oracle.com/javase/7/docs/api/java/net/HttpURLConnection.html#getErrorStream()>.
                // - `getInputStream()` should never return null... but let's err on the side of caution.
                //   See <https://docs.oracle.com/javase/7/docs/api/java/net/URLConnection.html#getInputStream()>.
                if (stream == null) {
                    throw new IOException(String.format("Null stream when reading connection (status %d)", code));
                }
                hostStatuses.put(host, new HostStatus(true));

                final byte[] rawResponse;
                String encoding = hostConnection.getContentEncoding();
                if (encoding != null && encoding.equals("gzip")) {
                    rawResponse = _toByteArray(new GZIPInputStream(stream));
                } else {
                    rawResponse = _toByteArray(stream);
                }

                // handle http errors
                if (codeIsError) {
                    if (code / 100 == 4) {
                        consumeQuietly(hostConnection);
                        throw new AlgoliaException(_getJSONObject(rawResponse).getString("message"), code);
                    } else {
                        consumeQuietly(hostConnection);
                        errors.add(new AlgoliaException(_toCharArray(stream), code));
                        continue;
                    }
                }
                return rawResponse;

            } catch (JSONException e) { // fatal
                consumeQuietly(hostConnection);
                throw new AlgoliaException("Invalid JSON returned by server", e);
            } catch (UnsupportedEncodingException e) { // fatal
                consumeQuietly(hostConnection);
                throw new AlgoliaException("Invalid encoding returned by server", e);
            } catch (IOException e) { // host error, continue on the next host
                hostStatuses.put(host, new HostStatus(false));
                consumeQuietly(hostConnection);
                errors.add(e);
            } finally {
                if (stream != null) {
                    try {
                        stream.close();
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
            }
        }

        String errorMessage = "All hosts failed: " + Arrays.toString(errors.toArray());
        // When several errors occurred, use the last one as the cause for the returned exception.
        Throwable lastError = errors.get(errors.size() - 1);
        throw new AlgoliaException(errorMessage, lastError);
    }

    /**
     * Ensures that the entity content is fully consumed and the content stream, if exists,
     * is closed.
     */
    private static void consumeQuietly(final HttpURLConnection connection) {
        try {
            int read = 0;
            while (read != -1) {
                read = connection.getInputStream().read();
            }
            connection.getInputStream().close();
            read = 0;
            while (read != -1) {
                read = connection.getErrorStream().read();
            }
            connection.getErrorStream().close();
            connection.disconnect();
        } catch (IOException e) {
            // no inputStream to close
        }
    }

    private void checkTimeout(int connectTimeout) {
        if (connectTimeout <= 0) {
            throw new IllegalArgumentException();
        }
    }

    /**
     * Get the hosts that are not considered down in a given list.
     *
     * @param hosts a list of hosts whose {@link HostStatus} will be checked.
     * @return the hosts considered up, or all hosts if none is known to be reachable.
     */
    private List<String> hostsThatAreUp(List<String> hosts) {
        List<String> upHosts = new ArrayList<>();
        for (String host : hosts) {
            if (isUpOrCouldBeRetried(host)) {
                upHosts.add(host);
            }
        }
        return upHosts.isEmpty() ? hosts : upHosts;
    }

    boolean isUpOrCouldBeRetried(String host) {
        HostStatus status = hostStatuses.get(host);
        return status == null || status.isUp || new Date().getTime() - status.lastTryTimestamp >= hostDownDelay;
    }

    // ----------------------------------------------------------------------
    // Utils
    // ----------------------------------------------------------------------

    /**
     * Abstract convenience implementation of {@link FutureRequest} using the client's default executors.
     */
    abstract protected class AsyncTaskRequest extends FutureRequest {
        /**
         * Construct a new request with the specified completion handler, executing on the client's search executor,
         * and calling the completion handler on the client's completion executor.
         *
         * @param completionHandler The completion handler to be notified of results. May be null if the caller omitted it.
         */
        protected AsyncTaskRequest(@Nullable CompletionHandler completionHandler) {
            this(completionHandler, searchExecutorService);
        }

        /**
         * Construct a new request with the specified completion handler, executing on the specified executor, and
         * calling the completion handler on the client's completion executor.
         *
         * @param completionHandler The completion handler to be notified of results. May be null if the caller omitted it.
         * @param requestExecutor   Executor on which to execute the request.
         */
        protected AsyncTaskRequest(@Nullable CompletionHandler completionHandler, @NonNull Executor requestExecutor) {
            super(completionHandler, requestExecutor, completionExecutor);
        }
    }
}