/*
 * Copyright (c) 2016 The original author or authors
 *
 * All rights reserved. This program and the accompanying materials
 * are made available under the terms of the Eclipse Public License v1.0
 * and Apache License v2.0 which accompanies this distribution.
 *
 *      The Eclipse Public License is available at
 *      http://www.eclipse.org/legal/epl-v10.html
 *
 *      The Apache License v2.0 is available at
 *      http://www.opensource.org/licenses/apache2.0.php
 *
 * You may elect to redistribute this code under either of these licenses.
 */

package io.engagingspaces.vertx.dataloader;

import io.vertx.core.CompositeFuture;
import io.vertx.core.Future;

import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;

/**
 * Data loader is a utility class that allows batch loading of data that is identified by a set of unique keys. For
 * each key that is loaded a separate {@link Future} is returned, that completes as the batch function completes.
 * Besides individual futures a {@link CompositeFuture} of the batch is available as well.
 * <p>
 * With batching enabled the execution will start after calling {@link DataLoader#dispatch()}, causing the queue of
 * loaded keys to be sent to the batch function, clears the queue, and returns the {@link CompositeFuture}.
 * <p>
 * As batch functions are executed the resulting futures are cached using a cache implementation of choice, so they
 * will only execute once. Individual cache keys can be cleared, so they will be re-fetched when referred to again.
 * It is also possible to clear the cache entirely, and prime it with values before they are used.
 * <p>
 * Both caching and batching can be disabled. Configuration of the data loader is done by providing a
 * {@link DataLoaderOptions} instance on creation.
 *
 * @param <K> type parameter indicating the type of the data load keys
 * @param <V> type parameter indicating the type of the data that is returned
 *
 * @author <a href="https://github.com/aschrijver/">Arnold Schrijver</a>
 */
public class DataLoader<K, V> {

    private final BatchLoader<K> batchLoadFunction;
    private final DataLoaderOptions loaderOptions;
    private final CacheMap<Object, Future<V>> futureCache;
    private final LinkedHashMap<K, Future<V>> loaderQueue;
    private final LinkedHashMap<CompositeFuture, LinkedHashMap<K, Future<V>>> dispatchedQueues;

    /**
     * Creates a new data loader with the provided batch load function, and default options.
     *
     * @param batchLoadFunction the batch load function to use
     */
    public DataLoader(BatchLoader<K> batchLoadFunction) {
        this(batchLoadFunction, null);
    }

    /**
     * Creates a new data loader with the provided batch load function and options.
     *
     * @param batchLoadFunction the batch load function to use
     * @param options           the batch load options
     */
    @SuppressWarnings("unchecked")
    public DataLoader(BatchLoader<K> batchLoadFunction, DataLoaderOptions options) {
        Objects.requireNonNull(batchLoadFunction, "Batch load function cannot be null");
        this.batchLoadFunction = batchLoadFunction;
        this.loaderOptions = options == null ? new DataLoaderOptions() : options;
        this.futureCache = loaderOptions.cacheMap().isPresent() ? (CacheMap<Object, Future<V>>) loaderOptions.cacheMap().get() : CacheMap.simpleMap();
        this.loaderQueue = new LinkedHashMap<>();
        this.dispatchedQueues = new LinkedHashMap<>();
    }

    /**
     * Requests to load the data with the specified key asynchronously, and returns a future of the resulting value.
     * <p>
     * If batching is enabled (the default), you'll have to call {@link DataLoader#dispatch()} at a later stage to
     * start batch execution. If you forget this call the future will never be completed (unless already completed,
     * and returned from cache).
     *
     * @param key the key to load
     * @return the future of the value
     */
    public Future<V> load(K key) {
        Objects.requireNonNull(key, "Key cannot be null");
        Object cacheKey = getCacheKey(key);
        if (loaderOptions.cachingEnabled() && futureCache.containsKey(cacheKey)) {
            return futureCache.get(cacheKey);
        }

        Future<V> future = Future.future();
        if (loaderOptions.batchingEnabled()) {
            loaderQueue.put(key, future);
        } else {
            CompositeFuture compositeFuture = batchLoadFunction.load(Collections.singleton(key));
            if (compositeFuture.succeeded()) {
                future.complete(compositeFuture.result().resultAt(0));
            } else {
                future.fail(compositeFuture.cause());
            }
        }
        if (loaderOptions.cachingEnabled()) {
            futureCache.set(cacheKey, future);
        }
        return future;
    }

    /**
     * Requests to load the list of data provided by the specified keys asynchronously, and returns a composite future
     * of the resulting values.
     * <p>
     * If batching is enabled (the default), you'll have to call {@link DataLoader#dispatch()} at a later stage to
     * start batch execution. If you forget this call the future will never be completed (unless already completed,
     * and returned from cache).
     *
     * @param keys the list of keys to load
     * @return the composite future of the list of values
     */
    public CompositeFuture loadMany(List<K> keys) {
        return CompositeFuture.join(keys.stream().map(this::load).collect(Collectors.toList()));
    }

    /**
     * Dispatches the queued load requests to the batch execution function and returns a composite future of the result.
     * <p>
     * If batching is disabled, or there are no queued requests, then a succeeded composite future is returned.
     *
     * @return the composite future of the queued load requests
     */
    public CompositeFuture dispatch() {
        if (!loaderOptions.batchingEnabled() || loaderQueue.size() == 0) {
            return CompositeFuture.join(Collections.emptyList());
        }
        CompositeFuture batch = batchLoadFunction.load(loaderQueue.keySet());
        dispatchedQueues.put(batch, new LinkedHashMap<>(loaderQueue));
        batch.setHandler(rh -> {
            AtomicInteger index = new AtomicInteger(0);
            dispatchedQueues.get(batch).forEach((key, future) -> {
                if (batch.succeeded(index.get())) {
                    future.complete(batch.resultAt(index.get()));
                } else {
                    future.fail(batch.cause(index.get()));
                }
                index.incrementAndGet();
            });
            dispatchedQueues.remove(batch);
        });
        loaderQueue.clear();
        return batch;
    }

    /**
     * Clears the future with the specified key from the cache, if caching is enabled, so it will be re-fetched
     * on the next load request.
     *
     * @param key the key to remove
     * @return the data loader for fluent coding
     */
    public DataLoader<K, V> clear(K key) {
        Object cacheKey = getCacheKey(key);
        futureCache.delete(cacheKey);
        return this;
    }

    /**
     * Clears the entire cache map of the loader.
     *
     * @return the data loader for fluent coding
     */
    public DataLoader<K, V> clearAll() {
        futureCache.clear();
        return this;
    }

    /**
     * Primes the cache with the given key and value.
     *
     * @param key   the key
     * @param value the value
     * @return the data loader for fluent coding
     */
    public DataLoader<K, V> prime(K key, V value) {
        Object cacheKey = getCacheKey(key);
        if (!futureCache.containsKey(cacheKey)) {
            futureCache.set(cacheKey, Future.succeededFuture(value));
        }
        return this;
    }

    /**
     * Primes the cache with the given key and error.
     *
     * @param key   the key
     * @param error the exception to prime instead of a value
     * @return the data loader for fluent coding
     */
    public DataLoader<K, V> prime(K key, Exception error) {
        Object cacheKey = getCacheKey(key);
        if (!futureCache.containsKey(cacheKey)) {
            futureCache.set(cacheKey, Future.failedFuture(error));
        }
        return this;
    }

    /**
     * Gets the object that is used in the internal cache map as key, by applying the cache key function to
     * the provided key.
     * <p>
     * If no cache key function is present in {@link DataLoaderOptions}, then the returned value equals the input key.
     *
     * @param key the input key
     * @return the cache key after the input is transformed with the cache key function
     */
    @SuppressWarnings("unchecked")
    public Object getCacheKey(K key) {
        return loaderOptions.cacheKeyFunction().isPresent() ?
                loaderOptions.cacheKeyFunction().get().getKey(key) : key;
    }
}