package com.algolia.search.saas.places;

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

import com.algolia.search.saas.AbstractQuery;
import com.algolia.search.saas.CompletionHandler;

/*
 * 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.
 */

/**
 * Search parameters for Algolia Places.
 *
 * @see PlacesClient#searchAsync(PlacesQuery, CompletionHandler)
 */
public class PlacesQuery extends AbstractQuery {
    // ----------------------------------------------------------------------
    // Construction
    // ----------------------------------------------------------------------

    /**
     * Construct an empty query.
     */
    public PlacesQuery() {
    }

    /**
     * Construct a query with the specified full text query.
     *
     * @param query Full text query.
     */
    public PlacesQuery(CharSequence query) {
        setQuery(query);
    }

    /**
     * Clone an existing query.
     *
     * @param other The query to be cloned.
     */
    public PlacesQuery(@NonNull PlacesQuery other) {
        super(other);
    }

    // ----------------------------------------------------------------------
    // High-level (typed) accessors
    // ----------------------------------------------------------------------

    private static final String KEY_QUERY = "query";

    /**
     * Set the full text query.
     */
    public @NonNull
    PlacesQuery setQuery(CharSequence query) {
        return set(KEY_QUERY, query);
    }

    public String getQuery() {
        return get(KEY_QUERY);
    }

    private static final String KEY_AROUND_LAT_LNG = "aroundLatLng";

    /**
     * Force to *first* search around a specific latitude/longitude.
     * The default is to search around the location of the user determined via his IP address (geoip).
     *
     * @param location The location to start the search at, or `null` to use the default.
     */
    public @NonNull
    PlacesQuery setAroundLatLng(LatLng location) {
        if (location == null) {
            return set(KEY_AROUND_LAT_LNG, null);
        } else {
            return set(KEY_AROUND_LAT_LNG, location.lat + "," + location.lng);
        }
    }

    public LatLng getAroundLatLng() {
        return LatLng.parse(get(KEY_AROUND_LAT_LNG));
    }

    private static final String KEY_AROUND_LAT_LNG_VIA_IP = "aroundLatLngViaIP";

    /**
     * Search *first* around the geolocation of the user found via his IP address.
     * Default: `true`.
     *
     * @param enabled Whether to use IP address to determine geolocation, or `null` to use the default.
     */
    public @NonNull
    PlacesQuery setAroundLatLngViaIP(Boolean enabled) {
        return set(KEY_AROUND_LAT_LNG_VIA_IP, enabled);
    }

    public @Nullable Boolean getAroundLatLngViaIP() {
        return parseBoolean(get(KEY_AROUND_LAT_LNG_VIA_IP));
    }

    private static final String KEY_AROUND_RADIUS = "aroundRadius";
    public static final int RADIUS_ALL = Integer.MAX_VALUE;

    /**
     * Change the radius for around latitude/longitude queries.
     *
     * @param radius The radius to set, or {@link #RADIUS_ALL} to disable stopping at a specific radius, or `null` to
     *               use the default.
     */
    public @NonNull
    PlacesQuery setAroundRadius(Integer radius) {
        if (radius == PlacesQuery.RADIUS_ALL) {
            return set(KEY_AROUND_RADIUS, "all");
        }
        return set(KEY_AROUND_RADIUS, radius);
    }

    /**
     * Get the current radius for around latitude/longitude queries.
     *
     * @return Query.RADIUS_ALL if set to 'all'.
     */
    public Integer getAroundRadius() {
        final String value = get(KEY_AROUND_RADIUS);
        if (value != null && value.equals("all")) {
            return PlacesQuery.RADIUS_ALL;
        }
        return parseInt(value);
    }

    private static final String KEY_HIGHLIGHT_POST_TAG = "highlightPostTag";

    public @NonNull
    PlacesQuery setHighlightPostTag(String tag) {
        return set(KEY_HIGHLIGHT_POST_TAG, tag);
    }

    public String getHighlightPostTag() {
        return get(KEY_HIGHLIGHT_POST_TAG);
    }

    private static final String KEY_HIGHLIGHT_PRE_TAG = "highlightPreTag";

    public @NonNull
    PlacesQuery setHighlightPreTag(String tag) {
        return set(KEY_HIGHLIGHT_PRE_TAG, tag);
    }

    public String getHighlightPreTag() {
        return get(KEY_HIGHLIGHT_PRE_TAG);
    }

    private static final String KEY_HITS_PER_PAGE = "hitsPerPage";

    /**
     * Set how many results you want to retrieve per search. Default: 20.
     */
    public @NonNull
    PlacesQuery setHitsPerPage(Integer nbHitsPerPage) {
        return set(KEY_HITS_PER_PAGE, nbHitsPerPage);
    }

    public Integer getHitsPerPage() {
        return parseInt(get(KEY_HITS_PER_PAGE));
    }

    /**
     * Types of places that can be searched for.
     */
    public static enum Type {
        /** City. */
        CITY,
        /** Country. */
        COUNTRY,
        /** Address. */
        ADDRESS,
        /** Bus stop. */
        BUS_STOP,
        /** Train station. */
        TRAIN_STATION,
        /** Town hall. */
        TOWN_HALL,
        /** Airport. */
        AIRPORT
    }

    private static final String KEY_TYPE = "type";

    /**
     * Set the type of place to search for.
     *
     * @param type Type of place to search for.
     */
    public @NonNull PlacesQuery setType(Type type) {
        if (type == null) {
            set(KEY_TYPE, null);
        } else {
            switch (type) {
                case CITY:
                    set(KEY_TYPE, "city");
                    break;
                case COUNTRY:
                    set(KEY_TYPE, "country");
                    break;
                case ADDRESS:
                    set(KEY_TYPE, "address");
                    break;
                case BUS_STOP:
                    set(KEY_TYPE, "busStop");
                    break;
                case TRAIN_STATION:
                    set(KEY_TYPE, "trainStation");
                    break;
                case TOWN_HALL:
                    set(KEY_TYPE, "townhall");
                    break;
                case AIRPORT:
                    set(KEY_TYPE, "airport");
                    break;
            }
        }
        return this;
    }

    public Type getType() {
        String value = get(KEY_TYPE);
        if (value != null) {
            switch (value) {
                case "city":
                    return Type.CITY;
                case "country":
                    return Type.COUNTRY;
                case "address":
                    return Type.ADDRESS;
                case "busStop":
                    return Type.BUS_STOP;
                case "trainStation":
                    return Type.TRAIN_STATION;
                case "townhall":
                    return Type.TOWN_HALL;
                case "airport":
                    return Type.AIRPORT;
            }
        }
        return null;
    }

    private static final String KEY_LANGUAGE = "language";

    /**
     * Restrict the search results to a single language.
     * You can pass two letters country codes (ISO 639-1).
     *
     * @param language The language used to return the results, or `null` to use all available languages.
     * @return This query.
     */
    public @NonNull PlacesQuery setLanguage(String language) {
        return set(KEY_LANGUAGE, language);
    }

    public String getLanguage() {
        return get(KEY_LANGUAGE);
    }

    private static final String KEY_COUNTRIES = "countries";

    /**
     * Restrict the search results to a specific list of countries.
     * You can pass two letters country codes (ISO 3166-1).
     *

     * Default: Search on the whole planet.
     *
     * @param countries The countries to restrict the search to, or `null` to search on the whole planet.
     * @return This query.
     */
    public @NonNull PlacesQuery setCountries(String... countries) {
        return set(KEY_COUNTRIES, buildJSONArray(countries));
    }

    public String[] getCountries() {
        return parseArray(get(KEY_COUNTRIES));
    }

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

    /**
     * Parse a query object from a URL query parameter string.
     *
     * @param queryParameters URL query parameter string.
     * @return The parsed query object.
     */
    protected static @NonNull
    PlacesQuery parse(@NonNull String queryParameters) {
        PlacesQuery query = new PlacesQuery();
        query.parseFrom(queryParameters);
        return query;
    }

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

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


/*
 * 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.helpers;

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

import com.algolia.search.saas.AlgoliaException;
import com.algolia.search.saas.CompletionHandler;
import com.algolia.search.saas.Index;
import com.algolia.search.saas.Query;
import com.algolia.search.saas.Request;
import com.algolia.search.saas.RequestOptions;

import org.json.JSONObject;

/**
 * Iterator to browse all index content.
 * <p>
 * This helper takes care of chaining API requests and calling back the handler block with the results, until:
 * - the end of the index has been reached;
 * - an error has been encountered;
 * - or the user cancelled the iteration.
 */
public class BrowseIterator {

    /**
     * Listener for {@link com.algolia.search.saas.helpers.BrowseIterator}.
     */
    public interface BrowseIteratorHandler {
        /**
         * Called at each batch of results.
         *
         * @param iterator The iterator where the results originate from.
         * @param result   The results (in case of success).
         * @param error    The error (in case of error).
         */
        void handleBatch(@NonNull BrowseIterator iterator, JSONObject result, AlgoliaException error);
    }

    /** The index being browsed. */
    private Index index;

    /** The query used to filter the results. */
    private Query query;

    /** Listener. */
    private BrowseIteratorHandler handler;

    /** Eventual request-specific options */
    private @Nullable RequestOptions requestOptions;

    /** Cursor to use for the next call, if any. */
    private String cursor;

    /** Whether the iteration has already started. */
    private transient boolean started = false;

    /** Whether the iteration has been cancelled by the user. */
    private transient boolean cancelled = false;

    /** The currently ongoing request, if any. */
    private Request request;

    /**
     * Construct a new browse iterator.
     * NOTE: The iteration does not start automatically. You have to call `start()` explicitly.
     *
     * @param index   The index to be browsed.
     * @param query   The query used to filter the results.
     * @param handler Handler called for each batch of results.
     */
    public BrowseIterator(@NonNull Index index, @NonNull Query query, @NonNull BrowseIteratorHandler handler) {
        this(index, query, null, handler);
    }

    /**
     * Construct a new browse iterator.
     * NOTE: The iteration does not start automatically. You have to call `start()` explicitly.
     *
     * @param index          The index to be browsed.
     * @param query          The query used to filter the results.
     * @param requestOptions Request-specific options.
     * @param handler        Handler called for each batch of results.
     */
    public BrowseIterator(@NonNull Index index, @NonNull Query query, @Nullable RequestOptions requestOptions, @NonNull BrowseIteratorHandler handler) {
        this.index = index;
        this.query = query;
        this.handler = handler;
        this.requestOptions = requestOptions;
    }

    /**
     * Start the iteration.
     */
    public void start() {
        if (started) {
            throw new IllegalStateException();
        }
        started = true;
        request = index.browseAsync(query, requestOptions, completionHandler);
    }

    /**
     * Cancel the iteration.
     * This cancels any currently ongoing request, and cancels the iteration.
     * The listener will not be called after the iteration has been cancelled.
     */
    public void cancel() {
        if (cancelled) {
            return;
        }
        request.cancel();
        request = null;
        cancelled = true;
    }

    /**
     * Determine if there is more content to be browsed.
     * WARNING: Can only be called from the handler, once the iteration has started.
     */
    public boolean hasNext() {
        if (!started) {
            throw new IllegalStateException();
        }
        return cursor != null;
    }

    private void next() {
        if (!hasNext()) {
            throw new IllegalStateException();
        }
        request = index.browseFromAsync(cursor, requestOptions, completionHandler);
    }

    private CompletionHandler completionHandler = new CompletionHandler() {
        @Override
        public void requestCompleted(JSONObject content, AlgoliaException error) {
            if (!cancelled) {
                handler.handleBatch(BrowseIterator.this, content, error);
                if (error == null) {
                    cursor = content.optString("cursor", null);
                    if (!cancelled && hasNext()) {
                        next();
                    }
                }
            }
        }
    };
}


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

/***
 * A searchable source of data
 */
public abstract class Searchable {
	/**
	 * Search inside this index (asynchronously).
	 *
	 * @param query             Search parameters. May be null to use an empty query.
	 * @param requestOptions    Request-specific options.
	 * @param completionHandler The listener that will be notified of the request's outcome.
	 * @return A cancellable request.
	 */
	public abstract Request searchAsync(@Nullable Query query, @Nullable final RequestOptions requestOptions, @Nullable CompletionHandler completionHandler);

	/**
	 * 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(@Nullable Query query, @Nullable CompletionHandler completionHandler) {
		return searchAsync(query, /* requestOptions: */ null, completionHandler);
	}

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

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

	/***
	 * If you override this please be sure it returns a unique string per instance
	 *
	 * @return
	 */
	@Override
	public String toString() {
		return super.toString();
	}
}


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

package com.algolia.search.saas;

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

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

import java.io.UnsupportedEncodingException;
import java.lang.ref.WeakReference;
import java.net.URLEncoder;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * Entry point to the Android API.
 * You must instantiate a Client object with your application ID and API key to start using Algolia Search
 * API.
 *

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

 */
public class Client extends AbstractClient {
    // ----------------------------------------------------------------------
    // Fields
    // ----------------------------------------------------------------------

    /**
     * Cache of already created indices. The values are weakly referenced to avoid memory leaks.
     */
    protected Map<String, WeakReference<Object>> indices = new HashMap<>();

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

    /**
     * Create a new Algolia Search client targeting the default hosts.
     *

     * NOTE: This is the recommended way to initialize a client is most use cases.
     *
     * @param applicationID The application ID (available in your Algolia Dashboard).
     * @param apiKey        A valid API key for the service.
     */
    public Client(@NonNull String applicationID, @NonNull String apiKey) {
        this(applicationID, apiKey, null);
    }

    /**
     * Create a new Algolia Search client with explicit hosts to target.
     *

     * NOTE: In most use cases, you should the default hosts. See {@link Client#Client(String, String)}.
     *
     * @param applicationID The application ID (available in your Algolia Dashboard).
     * @param apiKey        A valid API key for the service.
     * @param hosts         An explicit list of hosts to target, or null to use the default hosts.
     */
    public Client(@NonNull String applicationID, @NonNull String apiKey, @Nullable String[] hosts) {
        super(applicationID, apiKey, hosts, hosts);
        if (hosts == null) {
            // Initialize hosts to their default values.
            //
            // NOTE: The host list comes in two parts:
            //
            // 1. The fault-tolerant, load-balanced DNS host.
            // 2. The non-fault-tolerant hosts. Those hosts must be randomized to ensure proper load balancing in case
            //    of the first host's failure.
            //
            List<String> fallbackHosts = Arrays.asList(
                    applicationID + "-1.algolianet.com",
                    applicationID + "-2.algolianet.com",
                    applicationID + "-3.algolianet.com"
            );
            Collections.shuffle(fallbackHosts);

            List<String> readHosts = new ArrayList<>(fallbackHosts.size() + 1);
            readHosts.add(applicationID + "-dsn.algolia.net");
            readHosts.addAll(fallbackHosts);
            setReadHosts(readHosts.toArray(new String[readHosts.size()]));

            List<String> writeHosts = new ArrayList<>(fallbackHosts.size() + 1);
            writeHosts.add(applicationID + ".algolia.net");
            writeHosts.addAll(fallbackHosts);
            setWriteHosts(writeHosts.toArray(new String[writeHosts.size()]));
        }
    }

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

    public @NonNull String getApplicationID() {
        return super.getApplicationID();
    }

    // ----------------------------------------------------------------------
    // Index management
    // ----------------------------------------------------------------------

    /**
     * Create a proxy to an Algolia index (no server call required by this method).
     *
     * @param indexName The name of the index.
     * @return A new proxy to the specified index.
     * @deprecated You should now use {@link #getIndex(String)}, which re-uses instances with the same name.
     */
    public Index initIndex(@NonNull String indexName) {
        return new Index(this, indexName);
    }

    /**
     * Obtain a proxy to an Algolia index (no server call required by this method).
     *
     * @param indexName The name of the index.
     * @return A proxy to the specified index.
     */
    public @NonNull Index getIndex(@NonNull String indexName) {
        Index index = null;
        WeakReference<Object> existingIndex = indices.get(indexName);
        if (existingIndex != null) {
            index = (Index) existingIndex.get();
        }
        if (index == null) {
            index = new Index(this, indexName);
            indices.put(indexName, new WeakReference<Object>(index));
        }
        return index;
    }

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

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

    /**
     * List existing indexes.
     *
     * @param completionHandler The listener that will be notified of the request's outcome.
     * @return A cancellable request.
     */
    public Request listIndexesAsync(@NonNull CompletionHandler completionHandler) {
        return listIndexesAsync(/* requestOptions: */ null, completionHandler);
    }

    /**
     * Delete an index.
     *
     * @param indexName         Name of index to delete.
     * @param requestOptions    Request-specific options.
     * @param completionHandler The listener that will be notified of the request's outcome.
     * @return A cancellable request.
     */
    public Request deleteIndexAsync(final @NonNull String indexName, @Nullable final RequestOptions requestOptions, CompletionHandler completionHandler) {
        return new AsyncTaskRequest(completionHandler) {
            @NonNull
            @Override
            protected JSONObject run() throws AlgoliaException {
                return deleteIndex(indexName, requestOptions);
            }
        }.start();
    }

    /**
     * Delete an index.
     *
     * @param indexName         Name of index to delete.
     * @param completionHandler The listener that will be notified of the request's outcome.
     * @return A cancellable request.
     */
    public Request deleteIndexAsync(final @NonNull String indexName, CompletionHandler completionHandler) {
        return deleteIndexAsync(indexName, /* requestOptions: */ null, completionHandler);
    }

    /**
     * Move an existing index.
     * If the destination index already exists, its specific API keys will be preserved and the source index specific
     * API keys will be added.
     *
     * @param srcIndexName      Name of index to move.
     * @param dstIndexName      The new index name.
     * @param requestOptions    Request-specific options.
     * @param completionHandler The listener that will be notified of the request's outcome.
     * @return A cancellable request.
     */
    public Request moveIndexAsync(final @NonNull String srcIndexName, final @NonNull String dstIndexName, @Nullable final RequestOptions requestOptions, CompletionHandler completionHandler) {
        return new AsyncTaskRequest(completionHandler) {
            @NonNull
            @Override
            protected JSONObject run() throws AlgoliaException {
                return moveIndex(srcIndexName, dstIndexName, requestOptions);
            }
        }.start();
    }

    /**
     * Move an existing index.
     * If the destination index already exists, its specific API keys will be preserved and the source index specific
     * API keys will be added.
     *
     * @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 moveIndexAsync(final @NonNull String srcIndexName, final @NonNull String dstIndexName, CompletionHandler completionHandler) {
        return moveIndexAsync(srcIndexName, dstIndexName, /* requestOptions: */ null, completionHandler);
    }

    /**
     * Copy an existing index.
     * If the destination index already exists, its specific API keys will be preserved and the source index specific
     * API keys will be added.
     *
     * @param srcIndexName Name of index to copy.
     * @param dstIndexName The new index name.
     * @param requestOptions Request-specific options.
     * @param completionHandler The listener that will be notified of the request's outcome.
     * @return A cancellable request.
     */
    public Request copyIndexAsync(final @NonNull String srcIndexName, final @NonNull String dstIndexName, @Nullable final RequestOptions requestOptions, CompletionHandler completionHandler) {
        return new AsyncTaskRequest(completionHandler) {
            @NonNull
            @Override
            protected JSONObject run() throws AlgoliaException {
                return copyIndex(srcIndexName, dstIndexName, requestOptions);
            }
        }.start();
    }

    /**
     * Copy an existing index.
     * If the destination index already exists, its specific API keys will be preserved and the source index specific
     * API keys will be added.
     *
     * @param srcIndexName Name of index to copy.
     * @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 copyIndexAsync(final @NonNull String srcIndexName, final @NonNull String dstIndexName, CompletionHandler completionHandler) {
        return copyIndexAsync(srcIndexName, dstIndexName, /* requestOptions: */ null, completionHandler);
    }

    /**
     * Strategy when running multiple queries. See {@link Client#multipleQueriesAsync}.
     */
    public enum MultipleQueriesStrategy {
        /** Execute the sequence of queries until the end. */
        NONE("none"),
        /** Execute the sequence of queries until the number of hits is reached by the sum of hits. */
        STOP_IF_ENOUGH_MATCHES("stopIfEnoughMatches");

        private String rawValue;

        MultipleQueriesStrategy(String rawValue) {
            this.rawValue = rawValue;
        }

        public String toString() {
            return rawValue;
        }
    }

    /**
     * Run multiple queries, potentially targeting multiple indexes, with one API call.
     *
     * @param queries           The queries to run.
     * @param strategy          The strategy to use.
     * @param requestOptions    Request-specific options.
     * @param completionHandler The listener that will be notified of the request's outcome.
     * @return A cancellable request.
     */
    public Request multipleQueriesAsync(final @NonNull List<IndexQuery> queries, final MultipleQueriesStrategy strategy, @Nullable final RequestOptions requestOptions, @NonNull CompletionHandler completionHandler) {
        return new AsyncTaskRequest(completionHandler) {
            @NonNull
            @Override
            protected JSONObject run() throws AlgoliaException {
                return multipleQueries(queries, strategy == null ? null : strategy.toString(), requestOptions);
            }
        }.start();
    }

    /**
     * Run multiple queries, potentially targeting multiple indexes, with one API call.
     *
     * @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<IndexQuery> queries, final MultipleQueriesStrategy strategy, @NonNull CompletionHandler completionHandler) {
        return multipleQueriesAsync(queries, strategy, /* requestOptions: */ null, completionHandler);
    }

    /**
     * Batch operations.
     *
     * @param operations        List of operations.
     * @param requestOptions    Request-specific options.
     * @param completionHandler The listener that will be notified of the request's outcome.
     * @return A cancellable request.
     */
    public Request batchAsync(final @NonNull JSONArray operations, @Nullable final RequestOptions requestOptions, CompletionHandler completionHandler) {
        return new AsyncTaskRequest(completionHandler) {
            @NonNull
            @Override
            protected JSONObject run() throws AlgoliaException {
                return batch(operations, requestOptions);
            }
        }.start();
    }

    /**
     * Batch operations.
     *
     * @param operations        List of operations.
     * @param completionHandler The listener that will be notified of the request's outcome.
     * @return A cancellable request.
     */
    public Request batchAsync(final @NonNull JSONArray operations, CompletionHandler completionHandler) {
        return batchAsync(operations, /* requestOptions: */ null, completionHandler);
    }

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

    /**
     * List all existing indexes
     *
     * @param requestOptions Request-specific options.
     * @return a JSON Object in the form:
     * { "items": [ {"name": "contacts", "createdAt": "2013-01-18T15:33:13.556Z"},
     * {"name": "notes", "createdAt": "2013-01-18T15:33:13.556Z"}]}
     */
    protected JSONObject listIndexes(@Nullable RequestOptions requestOptions) throws AlgoliaException {
        return getRequest("/1/indexes/", /* urlParameters: */ null, false, requestOptions);
    }

    /**
     * Delete an index
     *
     * @param requestOptions Request-specific options.
     * @param indexName      the name of index to delete
     * @return an object containing a "deletedAt" attribute
     */
    protected JSONObject deleteIndex(String indexName, @Nullable RequestOptions requestOptions) throws AlgoliaException {
        try {
            return deleteRequest("/1/indexes/" + URLEncoder.encode(indexName, "UTF-8"), /* urlParameters: */ null, requestOptions);
        } catch (UnsupportedEncodingException e) {
            throw new RuntimeException(e);
        }
    }

    /**
     * Move an existing index.
     *
     * @param srcIndexName   the name of index to copy.
     * @param dstIndexName   the new index name that will contains a copy of srcIndexName (destination will be overriten if it already exist).
     * @param requestOptions Request-specific options.
     */
    protected JSONObject moveIndex(String srcIndexName, String dstIndexName, @Nullable RequestOptions requestOptions) throws AlgoliaException {
        try {
            JSONObject content = new JSONObject();
            content.put("operation", "move");
            content.put("destination", dstIndexName);
            return postRequest("/1/indexes/" + URLEncoder.encode(srcIndexName, "UTF-8") + "/operation", /* urlParameters: */ null, content.toString(), false, requestOptions);
        } catch (UnsupportedEncodingException e) {
            throw new RuntimeException(e);
        } catch (JSONException e) {
            throw new AlgoliaException(e.getMessage());
        }
    }

    /**
     * Copy an existing index.
     *
     * @param srcIndexName the name of index to copy.
     * @param dstIndexName the new index name that will contains a copy of srcIndexName (destination will be overriten if it already exist).
     * @param requestOptions Request-specific options.
     */
    protected JSONObject copyIndex(String srcIndexName, String dstIndexName, @Nullable RequestOptions requestOptions) throws AlgoliaException {
        try {
            JSONObject content = new JSONObject();
            content.put("operation", "copy");
            content.put("destination", dstIndexName);
            return postRequest("/1/indexes/" + URLEncoder.encode(srcIndexName, "UTF-8") + "/operation", /* urlParameters: */ null, content.toString(), false, requestOptions);
        } catch (UnsupportedEncodingException e) {
            throw new RuntimeException(e);
        } catch (JSONException e) {
            throw new AlgoliaException(e.getMessage());
        }
    }

    protected JSONObject multipleQueries(List<IndexQuery> queries, String strategy, @Nullable RequestOptions requestOptions) throws AlgoliaException {
        try {
            JSONArray requests = new JSONArray();
            for (IndexQuery indexQuery : queries) {
                requests.put(new JSONObject()
                        .put("indexName", indexQuery.getIndexName())
                        .put("params", indexQuery.getQuery().build())
                );
            }
            JSONObject body = new JSONObject().put("requests", requests);
            if (strategy != null) {
                body.put("strategy", strategy);
            }
            return postRequest("/1/indexes/*/queries", /* urlParameters: */ null, body.toString(), true, requestOptions);
        } catch (JSONException e) {
            throw new AlgoliaException(e.getMessage());
        }
    }

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


package com.algolia.search.saas;

import android.support.v4.util.LruCache;
import android.util.Pair;

import java.util.concurrent.TimeUnit;

/**
 * A cache that holds strong references to a limited number of values for a limited time.
 */
class ExpiringCache<K, V> {
    public static final TimeUnit expirationTimeUnit = TimeUnit.SECONDS;
    public static final int defaultExpirationTimeout = 120;
    public static final int defaultMaxSize = 64;
    public final int expirationTimeout; // Time after which a cache entry is invalidated

    private final LruCache<K, Pair<V, Long>> lruCache;

    public ExpiringCache(final int timeout, final int maxSize) {
        lruCache = new LruCache<>(maxSize);
        expirationTimeout = timeout;
    }

    public ExpiringCache() {
        this(defaultExpirationTimeout, defaultMaxSize);
    }

    /**
     * Puts a value in the cache, computing an expiration time
     *
     * @return the previous value for this key, if any
     */
    public V put(K key, V value) {
        V previous = null;

        synchronized (this) {
            long timeout = System.currentTimeMillis() + TimeUnit.MILLISECONDS.convert(expirationTimeout, expirationTimeUnit);
            final Pair<V, Long> previousPair = lruCache.put(key, new Pair<>(value, timeout));
            if (previousPair != null) {
                previous = previousPair.first;
            }
        }
        return previous;
    }

    /**
     * Get a value from the cache
     *
     * @return the cached value if it is still valid, else null.
     */
    synchronized public V get(K key) {
        final Pair<V, Long> cachePair = lruCache.get(key);
        if (cachePair != null && cachePair.first != null) {
            if (cachePair.second > System.currentTimeMillis()) {
                return cachePair.first;
            } else {
                lruCache.remove(key);
            }
        }
        return null;
    }

    /**
     * @return the number of entries in the cache.
     */
    public int size() {
        return lruCache.size();
    }

    /**
     * Reset the cache, keeping the current settings.
     */
    public void reset() {
        lruCache.evictAll();
    }
}


package com.hazelcast.simulator.utils;

import org.HdrHistogram.DoubleHistogram;
import org.HdrHistogram.EncodableHistogram;
import org.HdrHistogram.Histogram;
import org.HdrHistogram.HistogramLogReader;

import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.PrintStream;
import java.io.UnsupportedEncodingException;
import java.util.Date;
import java.util.Locale;
import java.util.Set;
import java.util.TreeSet;

import static com.hazelcast.simulator.utils.CommonUtils.closeQuietly;
import static com.hazelcast.simulator.utils.CommonUtils.exitWithError;

/**
 * Written by Gil Tene of Azul Systems, and released to the public domain,
 * as explained at http://creativecommons.org/publicdomain/zero/1.0/
 *
 * This is a modified {@link org.HdrHistogram.HistogramLogProcessor} that allows it to be subclassed. There is a pr pending
 * to modify the real one. So as soon as that is merged, we can get rid of this class.
 * https://github.com/HdrHistogram/HdrHistogram/pull/111
 *
 * @author Gil Tene
 */
public class HistogramLogProcessor extends Thread {

    public static final String versionString = "Histogram Log Processor version ---SIMULATOR--";

    protected final HistogramLogProcessorConfiguration config;

    protected HistogramLogReader logReader;

    protected static class HistogramLogProcessorConfiguration {
        public boolean verbose = false;
        public String outputFileName = null;
        public String inputFileName = null;
        public String tag = null;

        public double rangeStartTimeSec = 0.0;
        public double rangeEndTimeSec = Double.MAX_VALUE;

        public boolean logFormatCsv = false;
        public boolean listTags = false;
        public boolean allTags = false;

        public int percentilesOutputTicksPerHalf = 5;
        public Double outputValueUnitRatio = 1000000.0; // default to msec units for output.

        public boolean error = false;
        public String errorMessage = "";

        public HistogramLogProcessorConfiguration(final String[] args) {
            boolean askedForHelp = false;
            try {
                for (int i = 0; i < args.length; ++i) {
                    if (args[i].equals("-csv")) {
                        logFormatCsv = true;
                    } else if (args[i].equals("-v")) {
                        verbose = true;
                    } else if (args[i].equals("-listtags")) {
                        listTags = true;
                    } else if (args[i].equals("-alltags")) {
                        allTags = true;
                    } else if (args[i].equals("-i")) {
                        inputFileName = args[++i];
                    } else if (args[i].equals("-tag")) {
                        tag = args[++i];
                    } else if (args[i].equals("-start")) {
                        rangeStartTimeSec = Double.parseDouble(args[++i]);
                    } else if (args[i].equals("-end")) {
                        rangeEndTimeSec = Double.parseDouble(args[++i]);
                    } else if (args[i].equals("-o")) {
                        outputFileName = args[++i];
                    } else if (args[i].equals("-percentilesOutputTicksPerHalf")) {
                        percentilesOutputTicksPerHalf = Integer.parseInt(args[++i]);
                    } else if (args[i].equals("-outputValueUnitRatio")) {
                        outputValueUnitRatio = Double.parseDouble(args[++i]);
                    } else if (args[i].equals("-h")) {
                        askedForHelp = true;
                        throw new Exception("Help: " + args[i]);
                    } else {
                        throw new Exception("Invalid args: " + args[i]);
                    }
                }
            } catch (Exception e) {
                error = true;
                errorMessage = "Error: " + versionString + " launched with the following args:\n";

                for (String arg : args) {
                    errorMessage += arg + " ";
                }
                if (!askedForHelp) {
                    errorMessage += "\nWhich was parsed as an error, indicated by the following exception:\n" + e;
                    System.err.println(errorMessage);
                }

                final String validArgs =
                        "\"[-csv] [-v] [-i inputFileName] [-o outputFileName] [-tag tag] " +
                                "[-start rangeStartTimeSec] [-end rangeEndTimeSec] " +
                                "[-outputValueUnitRatio r] [-listtags]";

                System.err.println("valid arguments = " + validArgs);

                System.err.println(
                        " [-h]                        help\n" +
                                " [-v]                        Provide verbose error output\n" +
                                " [-csv]                      Use CSV format for output log files\n" +
                                " [-i logFileName]            File name of Histogram Log to process (default is standard input)\n" +
                                " [-o outputFileName]         File name to output to (default is standard output)\n" +
                                " [-tag tag]                  The tag (default no tag) of the histogram lines to be processed\n" +
                                " [-start rangeStartTimeSec]  The start time for the range in the file, in seconds (default 0.0)\n" +
                                " [-end rangeEndTimeSec]      The end time for the range in the file, in seconds (default is infinite)\n" +
                                " [-outputValueUnitRatio r]   The scaling factor by which to divide histogram recorded values units\n" +
                                "                             in output. [default = 1000000.0 (1 msec in nsec)]\n" +
                                " [-listtags]                 list all tags found on histogram lines the input file."
                );
                exitWithError();
            }
        }
    }

    private void outputTimeRange(final PrintStream log, final String title) {
        log.format(Locale.US, "#[%s between %.3f and", title, config.rangeStartTimeSec);
        if (config.rangeEndTimeSec < Double.MAX_VALUE) {
            log.format(" %.3f", config.rangeEndTimeSec);
        } else {
            log.format(" %s", "<Infinite>");
        }
        log.format(" seconds (relative to StartTime)]\n");
    }

    private void outputStartTime(final PrintStream log, final Double startTime) {
        log.format(Locale.US, "#[StartTime: %.3f (seconds since epoch), %s]\n",
                startTime, (new Date((long) (startTime * 1000))).toString());
    }

    int lineNumber = 0;

    private EncodableHistogram getIntervalHistogram() {
        EncodableHistogram histogram = null;
        try {
            histogram = logReader.nextIntervalHistogram(config.rangeStartTimeSec, config.rangeEndTimeSec);
        } catch (RuntimeException ex) {
            System.err.println("Log file parsing error at line number " + lineNumber +
                    ": line appears to be malformed.");
            if (config.verbose) {
                throw ex;
            } else {
                exitWithError();
            }
        }
        lineNumber++;
        return histogram;
    }

    private EncodableHistogram getIntervalHistogram(String tag) {
        EncodableHistogram histogram;
        if (tag == null) {
            do {
                histogram = getIntervalHistogram();
            } while ((histogram != null) && histogram.getTag() != null);
        } else {
            do {
                histogram = getIntervalHistogram();
            } while ((histogram != null) && !tag.equals(histogram.getTag()));
        }
        return histogram;
    }

    /**
     * Run the log processor with the currently provided arguments.
     */
    @Override
    public void run() {
        PrintStream timeIntervalLog = null;
        PrintStream histogramPercentileLog = System.out;
        Double firstStartTime = 0.0;
        boolean timeIntervalLogLegendWritten = false;

        if (config.listTags) {
            Set<String> tags = new TreeSet<String>();
            EncodableHistogram histogram;
            boolean nullTagFound = false;
            while ((histogram = getIntervalHistogram()) != null) {
                String tag = histogram.getTag();
                if (tag != null) {
                    tags.add(histogram.getTag());
                } else {
                    nullTagFound = true;
                }
            }
            System.out.println("Tags found in input file: ");
            if (nullTagFound) {
                System.out.println("[NO TAG (default)]");
            }
            for (String tag : tags) {
                System.out.println(tag);
            }
            // listtags does nothing other than list tags:
            return;
        }

        final String logFormat = buildLogFormat(config.logFormatCsv);

        try {
            if (config.outputFileName != null) {
                try {
                    timeIntervalLog = new PrintStream(new FileOutputStream(config.outputFileName), false, "UTF-8");
                    outputTimeRange(timeIntervalLog, "Interval percentile log");
                } catch (FileNotFoundException ex) {
                    System.err.println("Failed to open output file " + config.outputFileName);
                } catch (UnsupportedEncodingException e) {
                    System.err.println("Unsupported encoding: UTF-8");
                }
                String hgrmOutputFileName = config.outputFileName + ".hgrm";
                try {
                    histogramPercentileLog = new PrintStream(new FileOutputStream(hgrmOutputFileName), false, "UTF-8");
                    outputTimeRange(histogramPercentileLog, "Overall percentile distribution");
                } catch (FileNotFoundException ex) {
                    System.err.println("Failed to open percentiles histogram output file " + hgrmOutputFileName);
                } catch (UnsupportedEncodingException e) {
                    System.err.println("Unsupported encoding: UTF-8");
                }
            }

            EncodableHistogram intervalHistogram = getIntervalHistogram(config.tag);

            Histogram accumulatedRegularHistogram = null;
            DoubleHistogram accumulatedDoubleHistogram = null;

            if (intervalHistogram != null) {
                // Shape the accumulated histogram like the histograms in the log file (but clear their contents):
                if (intervalHistogram instanceof DoubleHistogram) {
                    accumulatedDoubleHistogram = ((DoubleHistogram) intervalHistogram).copy();
                    accumulatedDoubleHistogram.reset();
                    accumulatedDoubleHistogram.setAutoResize(true);
                } else {
                    accumulatedRegularHistogram = ((Histogram) intervalHistogram).copy();
                    accumulatedRegularHistogram.reset();
                    accumulatedRegularHistogram.setAutoResize(true);
                }
            }

            while (intervalHistogram != null) {
                if (intervalHistogram instanceof DoubleHistogram) {
                    if (accumulatedDoubleHistogram == null) {
                        throw new IllegalStateException("Encountered a DoubleHistogram line in a log of Histograms.");
                    }
                    accumulatedDoubleHistogram.add((DoubleHistogram) intervalHistogram);
                } else {
                    if (accumulatedRegularHistogram == null) {
                        throw new IllegalStateException("Encountered a Histogram line in a log of DoubleHistograms.");
                    }
                    accumulatedRegularHistogram.add((Histogram) intervalHistogram);
                }

                if ((firstStartTime == 0.0) && (logReader.getStartTimeSec() != 0.0)) {
                    firstStartTime = logReader.getStartTimeSec();

                    outputStartTime(histogramPercentileLog, firstStartTime);

                    if (timeIntervalLog != null) {
                        outputStartTime(timeIntervalLog, firstStartTime);
                    }
                }

                if (timeIntervalLog != null) {
                    if (!timeIntervalLogLegendWritten) {
                        timeIntervalLogLegendWritten = true;
                        timeIntervalLog.println(buildLegend(config.logFormatCsv));
                    }

                    Object[] statistics = intervalHistogram instanceof DoubleHistogram
                            ? buildDoubleHistogramStatistics((DoubleHistogram) intervalHistogram, accumulatedDoubleHistogram)
                            : buildRegularHistogramStatistics((Histogram) intervalHistogram, accumulatedRegularHistogram);

                    timeIntervalLog.format(Locale.US, logFormat, statistics);
                }

                intervalHistogram = getIntervalHistogram(config.tag);
            }

            if (accumulatedDoubleHistogram != null) {
                accumulatedDoubleHistogram.outputPercentileDistribution(histogramPercentileLog,
                        config.percentilesOutputTicksPerHalf, config.outputValueUnitRatio, config.logFormatCsv);
            } else {
                if (accumulatedRegularHistogram == null) {
                    // If there were no histograms in the log file, we still need an empty histogram for the
                    // one line output (shape/range doesn't matter because it is empty):
                    accumulatedRegularHistogram = new Histogram(1000000L, 2);
                }
                accumulatedRegularHistogram.outputPercentileDistribution(histogramPercentileLog,
                        config.percentilesOutputTicksPerHalf, config.outputValueUnitRatio, config.logFormatCsv);
            }
        } finally {
            if (config.outputFileName != null) {
                closeQuietly(timeIntervalLog);
                closeQuietly(histogramPercentileLog);
            }
        }
    }

    protected Object[] buildRegularHistogramStatistics(Histogram intervalHistogram, Histogram accumulatedRegularHistogram) {
        return new Object[]{((intervalHistogram.getEndTimeStamp() / 1000.0) - logReader.getStartTimeSec()),
                // values recorded during the last reporting interval
                intervalHistogram.getTotalCount(),
                intervalHistogram.getValueAtPercentile(50.0) / config.outputValueUnitRatio,
                intervalHistogram.getValueAtPercentile(90.0) / config.outputValueUnitRatio,
                intervalHistogram.getMaxValue() / config.outputValueUnitRatio,
                // values recorded from the beginning until now
                accumulatedRegularHistogram.getTotalCount(),
                accumulatedRegularHistogram.getValueAtPercentile(50.0) / config.outputValueUnitRatio,
                accumulatedRegularHistogram.getValueAtPercentile(90.0) / config.outputValueUnitRatio,
                accumulatedRegularHistogram.getValueAtPercentile(99.0) / config.outputValueUnitRatio,
                accumulatedRegularHistogram.getValueAtPercentile(99.9) / config.outputValueUnitRatio,
                accumulatedRegularHistogram.getValueAtPercentile(99.99) / config.outputValueUnitRatio,
                accumulatedRegularHistogram.getMaxValue() / config.outputValueUnitRatio};
    }

    protected Object[] buildDoubleHistogramStatistics(DoubleHistogram doubleIntervalHistogram, DoubleHistogram accumulatedDoubleHistogram) {
        return new Object[]{((doubleIntervalHistogram.getEndTimeStamp() / 1000.0) - logReader.getStartTimeSec()),
                // values recorded during the last reporting interval
                doubleIntervalHistogram.getTotalCount(),
                doubleIntervalHistogram.getValueAtPercentile(50.0) / config.outputValueUnitRatio,
                doubleIntervalHistogram.getValueAtPercentile(90.0) / config.outputValueUnitRatio,
                doubleIntervalHistogram.getMaxValue() / config.outputValueUnitRatio,
                // values recorded from the beginning until now
                accumulatedDoubleHistogram.getTotalCount(),
                accumulatedDoubleHistogram.getValueAtPercentile(50.0) / config.outputValueUnitRatio,
                accumulatedDoubleHistogram.getValueAtPercentile(90.0) / config.outputValueUnitRatio,
                accumulatedDoubleHistogram.getValueAtPercentile(99.0) / config.outputValueUnitRatio,
                accumulatedDoubleHistogram.getValueAtPercentile(99.9) / config.outputValueUnitRatio,
                accumulatedDoubleHistogram.getValueAtPercentile(99.99) / config.outputValueUnitRatio,
                accumulatedDoubleHistogram.getMaxValue() / config.outputValueUnitRatio};
    }

    protected String buildLegend(boolean cvs) {
        if (cvs) {
            return "\"Timestamp\",\"Int_Count\",\"Int_50%\",\"Int_90%\",\"Int_Max\",\"Total_Count\"," +
                    "\"Total_50%\",\"Total_90%\",\"Total_99%\",\"Total_99.9%\",\"Total_99.99%\",\"Total_Max\"";
        } else {
            return "Time: IntervalPercentiles:count ( 50% 90% Max ) TotalPercentiles:count ( 50% 90% 99% 99.9% 99.99% Max )";
        }
    }

    protected String buildLogFormat(boolean cvs) {
        if (cvs) {
            return "%.3f,%d,%.3f,%.3f,%.3f,%d,%.3f,%.3f,%.3f,%.3f,%.3f,%.3f\n";
        } else {
            return "%4.3f: I:%d ( %7.3f %7.3f %7.3f ) T:%d ( %7.3f %7.3f %7.3f %7.3f %7.3f %7.3f )\n";
        }
    }

    /**
     * Construct a {@link org.HdrHistogram.HistogramLogProcessor} with the given arguments
     * (provided in command line style).
     * <pre>
     * [-h]                        help
     * [-csv]                      Use CSV format for output log files
     * [-i logFileName]            File name of Histogram Log to process (default is standard input)
     * [-o outputFileName]         File name to output to (default is standard output)
     *                             (will replace occurrences of %pid and %date with appropriate information)
     * [-start rangeStartTimeSec]  The start time for the range in the file, in seconds (default 0.0)
     * [-end rangeEndTimeSec]      The end time for the range in the file, in seconds (default is infinite)
     * [-outputValueUnitRatio r]   The scaling factor by which to divide histogram recorded values units
     *                             in output. [default = 1000000.0 (1 msec in nsec)]"
     * </pre>
     *
     * @param args command line arguments
     * @throws FileNotFoundException if specified input file is not found
     */
    public HistogramLogProcessor(final String[] args) throws FileNotFoundException {
        this.setName("HistogramLogProcessor");
        config = new HistogramLogProcessorConfiguration(args);
        if (config.inputFileName != null) {
            logReader = new HistogramLogReader(config.inputFileName);
        } else {
            logReader = new HistogramLogReader(System.in);
        }
    }

    /**
     * main() method.
     *
     * @param args command line arguments
     */
    public static void main(final String[] args) {
        final HistogramLogProcessor processor;
        try {
            processor = new HistogramLogProcessor(args);
            processor.start();
        } catch (FileNotFoundException ex) {
            System.err.println("failed to open input file.");
        }
    }
}


/*
 * Copyright (c) 2008-2016, Hazelcast, Inc. All Rights Reserved.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package com.hazelcast.simulator.worker.performance;

import com.hazelcast.simulator.common.TestPhase;

import static java.lang.Math.max;

/**
 * Container to transfer performance statistics for some time window.
 *

 * Has methods to combine {@link PerformanceStats} instances by adding or setting maximum values.
 *
 * There is a lot of stuff in there, but the thing most important is the operationCount (in a given time window).
 */
public class PerformanceStats {

    public static final double INTERVAL_LATENCY_PERCENTILE = 99.9;

    private static final long EMPTY_OPERATION_COUNT = -1;
    private static final double EMPTY_THROUGHPUT = -1;

    private long operationCount;
    private double intervalThroughput;
    private double totalThroughput;
    private double intervalLatencyAvgNanos;
    private long intervalLatencyMaxNanos;
    private long intervalLatency999PercentileNanos;

    /**
     * Creates an empty {@link PerformanceStats} instance.
     */
    public PerformanceStats() {
        this.operationCount = EMPTY_OPERATION_COUNT;
        this.intervalThroughput = EMPTY_THROUGHPUT;
    }

    /**
     * Creates a {@link PerformanceStats} instance with values.
     *
     * @param operationCount                    Operation count value.
     * @param intervalThroughput                Throughput value for an interval.
     * @param totalThroughput                   Total throughput value.
     * @param intervalLatencyAvgNanos           Average latency for an interval.
     * @param intervalLatency999PercentileNanos 99.9 Percentile latency for an interval
     *                                          ({@link PerformanceStats#INTERVAL_LATENCY_PERCENTILE}).
     * @param intervalLatencyMaxNanos           Maximum latency for an interval.
     */
    public PerformanceStats(long operationCount,
                            double intervalThroughput,
                            double totalThroughput,
                            double intervalLatencyAvgNanos,
                            long intervalLatency999PercentileNanos,
                            long intervalLatencyMaxNanos) {
        this.operationCount = operationCount;
        this.intervalThroughput = intervalThroughput;
        this.totalThroughput = totalThroughput;
        this.intervalLatencyAvgNanos = intervalLatencyAvgNanos;
        this.intervalLatency999PercentileNanos = intervalLatency999PercentileNanos;
        this.intervalLatencyMaxNanos = intervalLatencyMaxNanos;
    }

    public PerformanceStats(PerformanceStats original) {
        this.operationCount = original.operationCount;
        this.intervalThroughput = original.intervalThroughput;
        this.totalThroughput = original.totalThroughput;
        this.intervalLatencyAvgNanos = original.intervalLatencyAvgNanos;
        this.intervalLatency999PercentileNanos = original.intervalLatency999PercentileNanos;
        this.intervalLatencyMaxNanos = original.intervalLatencyMaxNanos;
    }

    /**
     * Combines two {@link PerformanceStats} instances, e.g. from different Simulator Workers.
     *
     * @param other {@link PerformanceStats} which should be added to this instance
     */
    public void add(PerformanceStats other) {
        add(other, true);
    }

    /**
     * Combines {@link PerformanceStats} instances, e.g. from different Simulator Workers.
     *

     * For the real-time performance monitor during the {@link TestPhase#RUN} the
     * maximum value should be set, so we get the maximum operation count and throughput values of all {@link PerformanceStats}
     * instances of the last interval.
     *

     * For the total performance number and the performance per Simulator Agent, the added values should be set, so we get the
     * summed up operation count and throughput values.
     *

     * The method always sets the maximum values for latency.
     *
     * @param other                          {@link PerformanceStats} which should be added to this instance
     * @param addOperationCountAndThroughput {@code true} if operation count and throughput should be added,
     *                                       {@code false} if the maximum value should be set
     */
    public void add(PerformanceStats other, boolean addOperationCountAndThroughput) {
        if (other.isEmpty()) {
            return;
        }

        if (isEmpty()) {
            operationCount = other.operationCount;
            intervalThroughput = other.intervalThroughput;
            totalThroughput = other.totalThroughput;

            intervalLatencyAvgNanos = other.intervalLatencyAvgNanos;
            intervalLatency999PercentileNanos = other.intervalLatency999PercentileNanos;
            intervalLatencyMaxNanos = other.intervalLatencyMaxNanos;
        } else {
            if (addOperationCountAndThroughput) {
                operationCount += other.operationCount;
                intervalThroughput += other.intervalThroughput;
                totalThroughput += other.totalThroughput;
            } else {
                operationCount = max(operationCount, other.operationCount);
                intervalThroughput = max(intervalThroughput, other.intervalThroughput);
                totalThroughput = max(totalThroughput, other.totalThroughput);
            }

            intervalLatencyAvgNanos = max(intervalLatencyAvgNanos, other.intervalLatencyAvgNanos);
            intervalLatency999PercentileNanos = max(intervalLatency999PercentileNanos, other.intervalLatency999PercentileNanos);
            intervalLatencyMaxNanos = max(intervalLatencyMaxNanos, other.intervalLatencyMaxNanos);
        }
    }

    /**
     * Returns if the {@link PerformanceStats} instance is still empty.
     *
     * @return {@code true} if the {@link PerformanceStats} instance is empty, {@code false} otherwise
     */
    public boolean isEmpty() {
        return (operationCount == EMPTY_OPERATION_COUNT && intervalThroughput == EMPTY_THROUGHPUT);
    }

    public double getTotalThroughput() {
        return totalThroughput;
    }

    public long getOperationCount() {
        return operationCount;
    }

    public double getIntervalThroughput() {
        return intervalThroughput;
    }

    public double getIntervalLatencyAvgNanos() {
        return intervalLatencyAvgNanos;
    }

    public long getIntervalLatency999PercentileNanos() {
        return intervalLatency999PercentileNanos;
    }

    public long getIntervalLatencyMaxNanos() {
        return intervalLatencyMaxNanos;
    }

    @Override
    public String toString() {
        return "PerformanceStats{"
                + "operationCount=" + operationCount
                + ", intervalThroughput=" + intervalThroughput
                + ", totalThroughput=" + totalThroughput
                + ", intervalAvgLatencyNanos=" + intervalLatencyAvgNanos
                + ", intervalLatency999PercentileNanos=" + intervalLatency999PercentileNanos
                + ", intervalMaxLatencyNanos=" + intervalLatencyMaxNanos
                + '}';
    }

    public static PerformanceStats aggregateAll(PerformanceStats... stats) {
        PerformanceStats result = new PerformanceStats();
        for (PerformanceStats s : stats) {
            result.add(s);
        }
        return result;
    }
}


/*
 * Copyright (c) 2008-2016, Hazelcast, Inc. All Rights Reserved.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package com.hazelcast.simulator.utils;

import java.lang.Thread.UncaughtExceptionHandler;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.atomic.AtomicInteger;

import static com.hazelcast.simulator.utils.CommonUtils.rethrow;
import static com.hazelcast.simulator.utils.Preconditions.checkNotNull;

/**
 * Responsible for spawning and waiting for threads.
 *
 * If used in a test context {@link #testId} should be set to the testId of that test. This is needed to correlate an
 * exception to a specific test case. In a test context you should not set {@link #throwException} to true,
 * so the {@link ExceptionReporter} will be used.
 *
 * You can also use your own threads in Simulator tests, but make sure that you detect thrown exceptions and report them to the
 * {@link ExceptionReporter} by yourself.
 */
public class ThreadSpawner {

    private final List<Thread> threads = Collections.synchronizedList(new LinkedList<Thread>());
    private final ConcurrentMap<String, AtomicInteger> idMap = new ConcurrentHashMap<String, AtomicInteger>();

    private final String testId;
    private final boolean throwException;
    private final UncaughtExceptionHandler exceptionHandler;

    private volatile Throwable caughtException;

    /**
     * Creates a default {@link ThreadSpawner} for a test context.
     *
     * All occurring exceptions will be reported to the {@link ExceptionReporter}.
     *
     * @param testId testId to give reported exceptions a context
     */
    public ThreadSpawner(String testId) {
        this(testId, false);
    }

    /**
     * Creates a {@link ThreadSpawner} which can report exceptions to the {@link ExceptionReporter} or throw them directly.
     *
     * @param testId     testId to give reported exceptions a context
     * @param throwException true if exceptions should be directly thrown,
     *                       false if {@link ExceptionReporter} should be used
     */
    public ThreadSpawner(String testId, boolean throwException) {
        this.testId = testId;
        this.throwException = throwException;
        this.exceptionHandler = initExceptionHandler(throwException);
    }

    private UncaughtExceptionHandler initExceptionHandler(boolean throwException) {
        if (!throwException) {
            return null;
        }
        return new UncaughtExceptionHandler() {
            public void uncaughtException(Thread th, Throwable ex) {
                if (caughtException == null) {
                    caughtException = ex;
                }
            }
        };
    }

    /**
     * Spawns a new thread for the given {@link Runnable}.
     *
     * @param runnable the {@link Runnable} to execute
     * @return the created thread
     */
    public Thread spawn(Runnable runnable) {
        return spawn(testId + "-Thread", runnable);
    }

    /**
     * Spawns a new thread for the given {@link Runnable}.
     *
     * @param namePrefix the name prefix for the thread
     * @param runnable   the {@link Runnable} to execute
     * @return the created thread
     */
    public Thread spawn(String namePrefix, Runnable runnable) {
        checkNotNull(namePrefix, "namePrefix can't be null");
        checkNotNull(runnable, "runnable can't be null");

        String name = newName(namePrefix);
        Thread thread;
        if (throwException) {
            thread = new ThrowExceptionThread(name, runnable);
            thread.setUncaughtExceptionHandler(exceptionHandler);
        } else {
            thread = new ReportExceptionThread(testId, name, runnable);
        }
        threads.add(thread);
        thread.start();
        return thread;
    }

    /**
     * Waits for all threads to finish.
     *
     * If {@link #throwException} is true this method will throw the first occurred exception of a thread.
     */
    public void awaitCompletion() {
        for (Thread thread : threads) {
            try {
                thread.join();
            } catch (InterruptedException e) {
                throw rethrow(e);
            }
        }
        if (caughtException != null) {
            throw rethrow(caughtException);
        }
    }

    /**
     * Interrupts all running threads.
     */
    public void interrupt() {
        for (Thread thread : threads) {
            thread.interrupt();
        }
    }

    private String newName(String prefix) {
        AtomicInteger idGenerator = idMap.get(prefix);
        if (idGenerator == null) {
            idGenerator = new AtomicInteger();
            AtomicInteger result = idMap.putIfAbsent(prefix, idGenerator);
            idGenerator = result == null ? idGenerator : result;
        }

        return prefix + '-' + idGenerator.incrementAndGet();
    }

    private static class ThrowExceptionThread extends Thread {

        ThrowExceptionThread(String name, Runnable task) {
            super(task, name);
            setDaemon(true);
        }
    }

    private static class ReportExceptionThread extends Thread {

        private final String testId;

        ReportExceptionThread(String testId, String name, Runnable task) {
            super(task, name);
            this.testId = testId;
            setDaemon(true);
        }

        @Override
        @SuppressWarnings("PMD.AvoidCatchingThrowable")
        public void run() {
            try {
                super.run();
            } catch (Throwable t) {
                ExceptionReporter.report(testId, t);
            }
        }
    }
}


/*
 * Copyright (c) 2008-2016, Hazelcast, Inc. All Rights Reserved.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package com.hazelcast.simulator.utils;

public final class VersionUtils {

    private VersionUtils() {
    }

    public static boolean isMinVersion(String minVersion, String actualVersion) {
        return (versionCompare(minVersion, actualVersion) <= 0);
    }

    /**
     * Compares two version strings.
     *
     * Use this instead of String.compareTo() for a non-lexicographical comparison that works for version strings,
     * e.g. versionCompare("1.10", "1.6").
     *
     * Warning:
     * - It does not work if "1.10" is supposed to be equal to "1.10.0".
     * - Everything after a "-" is ignored.
     *
     * @param firstVersionString  a string of ordinal numbers separated by decimal points.
     * @param secondVersionString a string of ordinal numbers separated by decimal points.
     * @return The result is a negative integer if firstVersionString is _numerically_ less than secondVersionString.
     * The result is a positive integer if firstVersionString is _numerically_ greater than secondVersionString.
     * The result is zero if the strings are _numerically_ equal.
     */
    public static int versionCompare(String firstVersionString, String secondVersionString) {
        String[] firstVersion = parseVersionString(firstVersionString);
        String[] secondVersion = parseVersionString(secondVersionString);

        int i = 0;
        // set index to first non-equal ordinal or length of shortest version string
        while (i < firstVersion.length && i < secondVersion.length && firstVersion[i].equals(secondVersion[i])) {
            i++;
        }
        if (i < firstVersion.length && i < secondVersion.length) {
            // compare first non-equal ordinal number
            int diff = Integer.valueOf(firstVersion[i]).compareTo(Integer.valueOf(secondVersion[i]));
            return Integer.signum(diff);
        } else {
            // the strings are equal or one string is a substring of the other
            // e.g. "1.2.3" = "1.2.3" or "1.2.3" < "1.2.3.4"
            return Integer.signum(firstVersion.length - secondVersion.length);
        }
    }

    public static String[] parseVersionString(String versionString) {
        if (versionString.indexOf('-') != -1) {
            versionString = versionString.substring(0, versionString.indexOf('-'));
        }
        return versionString.split("\\.");
    }
}


/*
 * Copyright (c) 2008-2016, Hazelcast, Inc. All Rights Reserved.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package com.hazelcast.simulator.utils;

import org.apache.log4j.Logger;

import java.io.File;
import java.io.IOException;
import java.util.concurrent.atomic.AtomicLong;

import static com.hazelcast.simulator.utils.CommonUtils.throwableToString;
import static com.hazelcast.simulator.utils.FileUtils.getUserDir;
import static com.hazelcast.simulator.utils.FileUtils.rename;
import static com.hazelcast.simulator.utils.FileUtils.writeText;
import static com.hazelcast.simulator.utils.FormatUtils.NEW_LINE;

/**
 * Responsible for writing an exception to a file. Every exception file will have a unique name.
 */
public final class ExceptionReporter {

    static final int MAX_EXCEPTION_COUNT = 1000;

    static final AtomicLong FAILURE_ID = new AtomicLong(0);

    private static final Logger LOGGER = Logger.getLogger(ExceptionReporter.class);

    private ExceptionReporter() {
    }

    /**
     * Writes the cause to file.
     *
     * @param testId the id of the test that caused the exception. Is allowed to be <tt>null</tt> if it is not known which test
     *               caused the problem.
     * @param cause  the Throwable that should be reported.
     */
    public static void report(String testId, Throwable cause) {
        if (cause == null) {
            LOGGER.fatal("Can't call report with a null exception");
            return;
        }

        long exceptionCount = FAILURE_ID.incrementAndGet();

        if (exceptionCount > MAX_EXCEPTION_COUNT) {
            LOGGER.warn("Exception #" + exceptionCount + " detected. The maximum number of exceptions has been exceeded, so it"
                    + " won't be reported to the Agent.", cause);
            return;
        }

        LOGGER.warn("Exception #" + exceptionCount + " detected", cause);

        String targetFileName = exceptionCount + ".exception";

        File dir = getUserDir();
        File tmpFile = new File(dir, targetFileName + ".tmp");
        try {
            if (!tmpFile.createNewFile()) {
                throw new IOException("Could not create tmp file: " + tmpFile.getAbsolutePath());
            }
        } catch (IOException e) {
            LOGGER.fatal("Could not report exception; this means that this exception is not visible to the coordinator", e);
            return;
        }

        writeText(testId + NEW_LINE + throwableToString(cause), tmpFile);

        File file = new File(dir, targetFileName);
        LOGGER.info(file.getAbsolutePath());
        rename(tmpFile, file);
    }

    // just for testing
    public static void reset() {
        FAILURE_ID.set(0);
    }
}


/*
 * Copyright (c) 2008-2016, Hazelcast, Inc. All Rights Reserved.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package com.hazelcast.simulator.utils;

import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import org.apache.commons.lang3.text.StrSubstitutor;

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.Reader;
import java.util.LinkedList;
import java.util.List;
import java.util.regex.Pattern;

import static com.hazelcast.simulator.utils.CommonUtils.closeQuietly;
import static com.hazelcast.simulator.utils.EmptyStatement.ignore;
import static com.hazelcast.simulator.utils.Preconditions.checkNotNull;
import static java.lang.String.format;
import static java.nio.charset.Charset.forName;
import static java.util.Collections.emptyList;

@SuppressFBWarnings("DM_DEFAULT_ENCODING")
@SuppressWarnings("checkstyle:methodcount")
public final class FileUtils {

    public static final String USER_HOME = System.getProperty("user.home");

    private static final int READ_BUFFER_SIZE = 8192;
    private static final int COPY_BUFFER_SIZE = 1024;

    private static final Pattern VALID_FILE_NAME_PATTERN = Pattern.compile("^[a-zA-Z0-9-_]+$");

    private FileUtils() {
    }

    public static boolean isValidFileName(String fileName) {
        return VALID_FILE_NAME_PATTERN.matcher(fileName).matches();
    }

    public static File newFile(String path) {
        path = path.trim();
        if (path.equals("~")) {
            path = USER_HOME;
        } else if (path.startsWith("~" + File.separator)) {
            path = USER_HOME + path.substring(1);
        }
        path = new StrSubstitutor().replace(path);

        return new File(path);
    }

    public static File newFile(File file, String... items) {
        for (String item : items) {
            file = new File(file, item);
        }
        return file;
    }

    public static File newFile(String... items) {
        File file = newFile(items[0]);
        for (int i = 1; i < items.length; i++) {
            file = new File(file, items[i]);
        }
        return file;
    }

    public static void writeText(String text, File file) {
        checkNotNull(text, "Text can't be null");
        checkNotNull(file, "File can't be null");

        FileOutputStream stream = null;
        OutputStreamWriter streamWriter = null;
        BufferedWriter writer = null;
        try {
            stream = new FileOutputStream(file);
            streamWriter = new OutputStreamWriter(stream);
            writer = new BufferedWriter(streamWriter);
            writer.write(text);
            writer.close();
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        } finally {
            closeQuietly(writer);
            closeQuietly(streamWriter);
            closeQuietly(stream);
        }
    }

    public static File appendText(String text, String fileName) {
        File file = new File(fileName);
        appendText(text, file);
        return file;
    }

    public static void appendText(String text, File file) {
        checkNotNull(text, "Text can't be null");
        checkNotNull(file, "File can't be null");

        FileOutputStream stream = null;
        OutputStreamWriter streamWriter = null;
        BufferedWriter writer = null;
        try {
            stream = new FileOutputStream(file, true);
            streamWriter = new OutputStreamWriter(stream);
            writer = new BufferedWriter(streamWriter);
            writer.append(text);
            writer.close();
        } catch (IOException e) {
            throw new UncheckedIOException("Could not append text", e);
        } finally {
            closeQuietly(writer);
            closeQuietly(streamWriter);
            closeQuietly(stream);
        }
    }

    public static String toTextFromResourceFile(String fileName) {
        ClassLoader classLoader = FileUtils.class.getClassLoader();
        InputStream stream = classLoader.getResourceAsStream(fileName);
        try {
            return toTextFromStream(stream);
        } finally {
            closeQuietly(stream);
        }
    }

    public static String fileAsText(String fileName) {
        return fileAsText(new File(fileName));
    }

    public static String fileAsText(File file) {
        FileInputStream stream = null;
        try {
            stream = new FileInputStream(file);
            return toTextFromStream(stream);
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        } finally {
            closeQuietly(stream);
        }
    }

    private static String toTextFromStream(InputStream inputStream) {
        InputStreamReader streamReader = null;
        Reader reader = null;
        try {
            streamReader = new InputStreamReader(inputStream, forName("UTF-8"));
            reader = new BufferedReader(streamReader);

            StringBuilder builder = new StringBuilder();
            char[] buffer = new char[READ_BUFFER_SIZE];
            int read;
            while ((read = reader.read(buffer, 0, buffer.length)) > 0) {
                builder.append(buffer, 0, read);
            }
            return builder.toString();
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        } finally {
            closeQuietly(reader, streamReader);
        }
    }

    public static void deleteQuiet(File file) {
        if (file == null) {
            return;
        }
        try {
            delete(file);
        } catch (Exception ignored) {
            ignore(ignored);
        }
    }

    public static void delete(File file) {
        if (!file.exists()) {
            return;
        }

        if (file.isDirectory()) {
            File[] files = file.listFiles();
            if (files != null) {
                for (File fileInDirectory : files) {
                    delete(fileInDirectory);
                }
            }
        }

        if (!file.delete()) {
            throw new UncheckedIOException("Failed to delete file: " + file);
        }
    }

    public static File ensureExistingFile(String fileName) {
        return ensureExistingFile(new File(fileName));
    }

    public static File ensureExistingFile(File parent, String fileName) {
        return ensureExistingFile(new File(parent, fileName));
    }

    public static File ensureExistingFile(File file) {
        if (file.isFile()) {
            return file;
        }

        if (file.isDirectory()) {
            throw new IllegalArgumentException(format("File [%s] is a directory", file.getAbsolutePath()));
        }

        if (!file.exists()) {
            try {
                if (!file.createNewFile()) {
                    throw new UncheckedIOException("Could not create file: " + file.getAbsolutePath());
                }
            } catch (IOException e) {
                throw new UncheckedIOException(e);
            }
        }

        return file;
    }

    public static File ensureExistingDirectory(String dirName) {
        return ensureExistingDirectory(new File(dirName));
    }

    public static File ensureExistingDirectory(File parent, String dirName) {
        return ensureExistingDirectory(new File(parent, dirName));
    }

    public static File ensureExistingDirectory(File dir) {
        if (dir.isDirectory()) {
            return dir;
        }

        if (dir.isFile()) {
            throw new IllegalArgumentException(format("File [%s] is not a directory", dir.getAbsolutePath()));
        }

        // we don't care about the result because multiple threads are allowed to call this method concurrently
        // and therefore mkdirs() can return false if the directory has been created by another thread
        //noinspection ResultOfMethodCallIgnored
        dir.mkdirs();

        // we just need to make sure the directory is created
        if (!dir.exists()) {
            throw new UncheckedIOException("Could not create directory: " + dir.getAbsolutePath());
        }

        return dir;
    }

    /**
     * Makes sure the directory exists and is fresh.
     *
     * If the directory doesn't exist, it is created.
     *
     * If the directory does exist, it is fully deleted and then recreated.
     *
     * @param dir the directory
     * @return the directory
     */
    public static File ensureFreshDirectory(File dir) {
        delete(dir);
        return ensureExistingDirectory(dir);
    }

    public static File ensureNewDirectory(File dir) {
        if (dir.exists()) {
            throw new UncheckedIOException(format("Directory [%s] already exists", dir.getAbsoluteFile()));
        }
        if (!dir.mkdirs()) {
            throw new UncheckedIOException("Could not create directory [%s]" + dir.getAbsolutePath());
        }
        return dir;
    }

    public static void rename(File source, File target) {
        if (!source.exists()) {
            return;
        }
        if (!source.renameTo(target)) {
            throw new UncheckedIOException(format("Could not rename [%s] to [%s]",
                    source.getAbsolutePath(), target.getAbsolutePath()));
        }
    }

    public static void copy(File file, OutputStream outputStream) {
        InputStream inputStream = null;
        try {
            inputStream = new FileInputStream(file);

            byte[] buffer = new byte[COPY_BUFFER_SIZE];
            while (true) {
                int readCount = inputStream.read(buffer);
                if (readCount < 0) {
                    break;
                }
                outputStream.write(buffer, 0, readCount);
            }
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        } finally {
            closeQuietly(inputStream);
        }
    }

    public static void copy(File from, File to) {
        FileOutputStream out;
        try {
            out = new FileOutputStream(to);
        } catch (FileNotFoundException e) {
            throw new UncheckedIOException(e);
        }
        try {
            copy(from, out);
        } finally {
            closeQuietly(out);
        }

        if (from.canExecute()) {
            to.setExecutable(true);
        }
    }

    public static File getUserDir() {
        String userDirTest = System.getProperty("user.dir.test");
        return userDirTest == null ? new File(System.getProperty("user.dir")) : new File(userDirTest);
    }

    public static String getUserHomePath() {
        String userDirTest = System.getProperty("user.dir.test");
        return userDirTest == null ? System.getProperty("user.home") : new File(userDirTest).getAbsolutePath();
    }

    public static File getSimulatorHome() {
        String home = System.getProperty("SIMULATOR_HOME");
        if (home != null) {
            return new File(home);
        }

        home = System.getenv("SIMULATOR_HOME");
        return home != null ? new File(home) : getUserDir();
    }

    public static List<File> getFilesFromClassPath(String classpath) {
        if (classpath == null) {
            return emptyList();
        }

        List<File> files = new LinkedList<File>();
        for (String filePath : classpath.split(";")) {
            File file = new File(filePath);

            if (file.getName().contains("*")) {
                File parent = file.getParentFile();
                if (parent == null || !parent.isDirectory()) {
                    throw new UncheckedIOException(format("Could not find matching files for wildcard classpath [%s]", filePath));
                }

                String regex = file.getName().replace("*", "(.*)");
                File[] parentFiles = parent.listFiles();
                if (parentFiles != null) {
                    for (File child : parentFiles) {
                        if (child.getName().matches(regex)) {
                            files.add(child);
                        }
                    }
                }
            } else if (file.exists()) {
                files.add(file);
            } else {
                throw new UncheckedIOException(format("Cannot convert classpath to java.io.File. [%s] doesn't exist", filePath));
            }
        }

        return files;
    }

    /**
     * Copies a directory recursively.
     *
     * @param src    the src directory
     * @param target the target directory
     */
    public static void copyDirectory(File src, File target) {
        checkNotNull(src, "src can't be null");
        checkNotNull(target, "target can't be null");

        File[] files = src.listFiles();
        if (files == null) {
            return;
        }

        for (File srcFile : files) {
            if (srcFile.isDirectory()) {
                File targetChild = new File(target, srcFile.getName());
                ensureExistingDirectory(targetChild);
                copyDirectory(srcFile, targetChild);
            } else {
                copyFileToDirectory(srcFile, target);
            }
        }
    }

    public static void copyFileToDirectory(File sourceFile, File targetDirectory) {
        File targetFile = newFile(targetDirectory, sourceFile.getName());
        copy(sourceFile, targetFile);
    }

    public static File getConfigurationFile(String filename) {
        File file = new File(getUserDir(), filename).getAbsoluteFile();
        return file.exists() ? file : newFile(getSimulatorHome(), "conf", filename).getAbsoluteFile();
    }
}