/**
 * The contents of this file are subject to the license and copyright
 * detailed in the LICENSE and NOTICE files at the root of the source
 * tree and available online at
 *
 * http://www.dspace.org/license/
 */

package org.dspace.importer.external.service.components;

import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.Callable;
import java.util.concurrent.locks.ReentrantLock;
import javax.annotation.Resource;

import org.apache.logging.log4j.Logger;
import org.dspace.importer.external.exception.MetadataSourceException;
import org.dspace.importer.external.exception.SourceExceptionHandler;

/**
 * This class contains primitives to handle request timeouts and to retry requests.
 * This is achieved by classifying exceptions as fatal or as non fatal/retryable.
 * Evidently only subclasses can make the proper determination of what is retryable and what isn't.
 * This is useful in case the service employs throttling and to deal with general network issues.
 *
 * @author Roeland Dillen (roeland at atmire dot com)
 * @author Antoine Snyers (antoine at atmire dot com)
 */
public abstract class AbstractRemoteMetadataSource {

    protected long lastRequest = 0;
    protected long interRequestTime;

    protected ReentrantLock lock = new ReentrantLock();

    protected int maxRetry = 20;
    protected int retry;
    protected String operationId;
    protected String warning;

    protected Map<Class, List<SourceExceptionHandler>> exceptionHandlersMap;
    protected Exception error;

    /**
     * Constructs an empty MetadataSource class object and initializes the Exceptionhandlers
     */
    protected AbstractRemoteMetadataSource() {
        initExceptionHandlers();
    }

    /**
     * initialize the exceptionHandlersMap with an empty {@link java.util.LinkedHashMap}
     */
    protected void initExceptionHandlers() {
        exceptionHandlersMap = new LinkedHashMap<>();
        // if an exception is thrown that is not in there, it is not recoverable and the retry chain will stop
        // by default all exceptions are fatal, but subclasses can add their own handlers for their own exceptions
    }

    /**
     * Return the warning message used for logging during exception catching
     *
     * @return a "warning" String
     */
    public String getWarning() {
        return warning;
    }

    /**
     * Set the warning message used for logging
     *
     * @param warning warning message
     */
    public void setWarning(String warning) {
        this.warning = warning;
    }

    /**
     * Return the number of retries that have currently been undertaken
     *
     * @return the number of retries
     */
    public int getRetry() {
        return retry;
    }

    /**
     * Return the number of max retries that can be undertaken before separate functionality kicks in
     *
     * @return maximum number of retries
     */
    public int getMaxRetry() {
        return maxRetry;
    }

    /**
     * Set the number of maximum retries before throwing on the exception
     *
     * @param maxRetry maximum number of retries
     */
    @Resource(name = "maxRetry")
    public void setMaxRetry(int maxRetry) {
        this.maxRetry = maxRetry;
    }

    /**
     * Retrieve the operationId
     *
     * @return A randomly generated UUID. generated during the retry method
     */
    public String getOperationId() {
        return operationId;
    }

    /**
     * Retrieve the last encountered exception
     *
     * @return An Exception object, the last one encountered in the retry method
     */
    public Exception getError() {
        return error;
    }

    /**
     * Set the last encountered error
     *
     * @param error exception to set
     */
    public void setError(Exception error) {
        this.error = error;
    }

    /**
     * log4j logger
     */
    private static Logger log = org.apache.logging.log4j.LogManager.getLogger(AbstractRemoteMetadataSource.class);

    /**
     * Command pattern implementation. the callable.call method will be retried
     * until it either succeeds or reaches the try limit. Maybe this should have
     * a backoff algorithm instead of waiting a fixed time.
     *
     * @param callable the callable to call. See the classes with the same name as
     *                 the public methods of this class.
     * @param <T>      return type. Generics for type safety.
     * @return The result of the call
     * @throws org.dspace.importer.external.exception.MetadataSourceException if something unrecoverable happens (e.g
     *                                                                        . network failures)
     */
    protected <T> T retry(Callable<T> callable) throws MetadataSourceException {

        retry = 0;
        operationId = UUID.randomUUID().toString();
        while (true) {
            try {
                lock.lock();
                this.error = null;
                long time = System.currentTimeMillis() - lastRequest;
                if ((time) < interRequestTime) {
                    Thread.sleep(interRequestTime - time);
                }
                try {
                    init();
                } catch (Exception e) {
                    throwSourceException(retry, e, operationId);
                }
                log.info("operation " + operationId + " started");
                T response = callable.call();
                log.info("operation " + operationId + " successful");
                return response;
            } catch (Exception e) {
                this.error = e;
                if (retry > maxRetry) {
                    throwSourceException(retry, e, operationId);
                }
                handleException(retry, e, operationId);

                // No MetadataSourceException has interrupted the loop
                retry++;
                log.warn("Error in trying operation " + operationId + " " + retry + " " + warning + ", retrying !", e);

            } finally {
                this.lastRequest = System.currentTimeMillis();
                lock.unlock();
            }

            try {
                Thread.sleep(1000L);
            } catch (InterruptedException e) {
                throwSourceException(retry, e, operationId);
            }

        }

    }

    /**
     * Handles a given exception or throws on a
     * {@link org.dspace.importer.external.exception.MetadataSourceException} if no ExceptionHandler is set
     *
     * @param retry       The number of retries before the exception was thrown on
     * @param exception   The exception to handle
     * @param operationId The id of the operation that threw the exception
     * @throws MetadataSourceException if no ExceptionHandler is configured for the given exception
     */
    protected void handleException(int retry, Exception exception, String operationId) throws MetadataSourceException {

        List<SourceExceptionHandler> exceptionHandlers = getExceptionHandler(exception);
        if (exceptionHandlers != null && !exceptionHandlers.isEmpty()) {
            for (SourceExceptionHandler exceptionHandler : exceptionHandlers) {
                exceptionHandler.handle(this);
            }
        } else {
            throwSourceException(retry, exception, operationId);
        }
    }

    /**
     * Retrieve a list of SourceExceptionHandler objects that have an instanceof the exception configured to them.
     *
     * @param exception The exception to base the retrieval of
     *                  {@link org.dspace.importer.external.exception.SourceExceptionHandler} on
     * @return a list of {@link org.dspace.importer.external.exception.SourceExceptionHandler} objects
     */
    protected List<SourceExceptionHandler> getExceptionHandler(Exception exception) {
        for (Class aClass : exceptionHandlersMap.keySet()) {
            if (aClass.isInstance(exception)) {
                return exceptionHandlersMap.get(aClass);
            }
        }
        return null;
    }

    /**
     * Throw a {@link MetadataSourceException}
     *
     * @param retry       The number of retries before the exception was thrown on
     * @param exception   The exception to throw
     * @param operationId The id of the operation that threw the exception
     * @throws MetadataSourceException if no ExceptionHandler is configured for the given exception
     */
    protected void throwSourceException(int retry, Exception exception, String operationId)
        throws MetadataSourceException {
        throwSourceExceptionHook();
        log.error("Source exception " + exception.getMessage(), exception);
        throw new MetadataSourceException("At retry of operation " + operationId + " " + retry, exception);
    }

    /**
     * A specified point where methods can be specified or callbacks can be executed
     */
    protected void throwSourceExceptionHook() {

    }

    /**
     * Attempts to init a session
     *
     * @throws Exception on generic exception
     */
    public abstract void init() throws Exception;

    public void setInterRequestTime(final long interRequestTime) {
        this.interRequestTime = interRequestTime;
    }
}
