/*
 * Copyright 2019 American Express Travel Related Services Company, Inc.
 *
 * 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 io.americanexpress.busybee.internal;

import androidx.annotation.GuardedBy;
import androidx.annotation.NonNull;

import java.util.ArrayList;
import java.util.EnumSet;
import java.util.Iterator;
import java.util.List;
import java.util.concurrent.Executor;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.logging.Logger;

import io.americanexpress.busybee.BusyBee;

import static io.americanexpress.busybee.BusyBee.Category.defaultCategory;
import static java.lang.String.format;
import static java.lang.System.identityHashCode;
import static java.util.Locale.US;

/**
 * This allows the app to let Espresso (test framework) know when it is busy and Espresso should wait.
 * It is not directly tied to Espresso and could be used in any case where you need to know if the app is "busy".
 * <p>
 * Call busyWith when you start being "busy"
 * Call completed when you stop being "busy" and start being "idle".
 * <p>
 * Generally, you should call completed from a finally block.
 * <p>
 * Espresso will wait for the app to be "idle" (i.e. not busy).
 * <p>
 * Proper use of the BusyBee will avoid having to "wait" or "sleep" in tests.
 * BE SURE NOT BE "BUSY" LONGER THAN NECESSARY, otherwise it will slow down your tests.
 */
public class RealBusyBee implements BusyBee {
    private static final Logger log = Logger.getLogger("io.americanexpress.busybee");

    @GuardedBy("lock")
    private final SetMultiMap<Category, Object> operationsInProgress = new SetMultiMap<>();
    @GuardedBy("lock")
    private final EnumSet<Category> currentlyTrackedCategories = EnumSet.allOf(Category.class);
    @GuardedBy("lock")
    private final List<NoLongerBusyCallback> noLongerBusyCallbacks = new ArrayList<>(1); // Espresso use case will only have 1 callback

    private final Lock lock = new ReentrantLock();
    private final Category defaultCategory = defaultCategory();
    private final Executor completedOnThread;

    public RealBusyBee(Executor completedOnThread) {
        this.completedOnThread = completedOnThread;
    }


    @NonNull
    @Override
    public String getName() {
        lock.lock();
        try {
            return format(US, this.getClass().getSimpleName() + "@%d with operations: %s",
                    identityHashCode(this),
                    operationsInProgress);
        } finally {
            lock.unlock();
        }
    }

    @Override
    public void busyWith(@NonNull Object operation) {
        busyWith(operation, defaultCategory);
    }

    @Override
    public void busyWith(@NonNull Object operation, @NonNull final Category category) {
        //noinspection ConstantConditions
        if (operation == null) {
            throw new NullPointerException("Can not be `busyWith` null, operation must be non-null");
        }
        lock.lock();
        boolean wasAdded;
        try {
            wasAdded = operationsInProgress.add(category, operation);
            if (wasAdded) {
                log.info("busyWith -> [" + operation + "] was added to active operations in category " + category);
            }
        } finally {
            lock.unlock();
        }
    }

    @Override
    public void registerNoLongerBusyCallback(@NonNull final NoLongerBusyCallback noLongerBusyCallback) {
        lock.lock();
        try {
            noLongerBusyCallbacks.add(noLongerBusyCallback);
        } finally {
            lock.unlock();
        }
    }

    @Override
    public void payAttentionToCategory(@NonNull final Category category) {
        lock.lock();
        try {
            log.info("Paying attention to category: " + category);
            currentlyTrackedCategories.add(category);
        } finally {
            lock.unlock();
        }
    }

    @Override
    public void ignoreCategory(@NonNull final Category category) {
        lock.lock();
        try {
            log.info("Ignoring category: " + category);
            final boolean wasBusyBefore = isBusy();
            final boolean wasRemoved = currentlyTrackedCategories.remove(category);
            final boolean notBusyNow = isNotBusy();
            if (wasRemoved && wasBusyBefore && notBusyNow) {
                notifyNoLongerBusyCallbacks();
            }
        } finally {
            lock.unlock();
        }
    }

    @Override
    public void completedEverythingInCategory(@NonNull final Category category) {
        completedOnThread.execute(new Runnable() {
            @Override
            public void run() {
                lock.lock();
                try {
                    for (Iterator<Object> iterator = operationsInProgress.valuesIterator(category); iterator.hasNext(); ) {
                        Object next = iterator.next();
                        completeOnCurrentThread(next, iterator);
                    }
                } finally {
                    lock.unlock();
                }
            }

            @Override
            public String toString() {
                return "completedEverythingInCategory(" + category.toString() + ")";
            }
        });
    }

    @Override
    public void completedEverything() {
        completedOnThread.execute(new Runnable() {
            @Override
            public void run() {
                lock.lock();
                try {
                    for (Iterator<Object> iterator = operationsInProgress.valuesIterator(); iterator.hasNext(); ) {
                        Object next = iterator.next();
                        completeOnCurrentThread(next, iterator);
                    }
                } finally {
                    lock.unlock();
                }
            }

            @Override
            public String toString() {
                return "completedEverything()";
            }
        });
    }

    @Override
    public void completedEverythingMatching(@NonNull OperationMatcher matcher) {
        completedOnThread.execute(new Runnable() {
            @Override
            public void run() {
                lock.lock();
                try {
                    for (Iterator<Object> iterator = operationsInProgress.valuesIterator(); iterator.hasNext(); ) {
                        Object next = iterator.next();
                        if (matcher.matches(next)) {
                            completeOnCurrentThread(next, iterator);
                        }
                    }
                } finally {
                    lock.unlock();
                }
            }

            @Override
            public String toString() {
                return "completedEverythingMatching(" + matcher.toString() + ")";
            }
        });
    }

    @Override
    public void completed(@NonNull final Object operation) {
        completedOnThread.execute(new Runnable() {
            @Override
            public void run() {
                completeOnCurrentThread(operation, null);
            }

            @Override
            public String toString() {
                return "completed(" + operation.toString() + ")";
            }
        });
    }

    /**
     * Precondition: Iterator must be pointing to the operation passed in (or iterator must be null).
     * <p>
     * This method "completes" the operation on the current thread.
     *
     * @param operation the operation to be completed
     * @param iterator  must be pointing to operation
     */
    private void completeOnCurrentThread(Object operation, Iterator<Object> iterator) {
        if (operation == null) {
            throw new NullPointerException("null can not be `completed` null, operation must be non-null");
        }
        lock.lock();
        boolean wasRemoved;
        try {
            if (iterator != null) {
                // if the collection is being iterated,
                // then we HAVE to use the iterator for removal to avoid ConcurrentModificationException
                iterator.remove();
                wasRemoved = true;
            } else {
                wasRemoved = operationsInProgress.removeValue(operation);
            }
            if (wasRemoved) {
                log.info("completed -> [" + operation + "] was removed from active operations");
            }
            if (wasRemoved && isNotBusy()) {
                notifyNoLongerBusyCallbacks();
            }
        } finally {
            lock.unlock();
        }
    }

    private void notifyNoLongerBusyCallbacks() {
        for (NoLongerBusyCallback noLongerBusyCallback : noLongerBusyCallbacks) {
            log.info("All operations are now finished, we are now idle");
            noLongerBusyCallback.noLongerBusy();
        }
    }

    @Override
    public boolean isNotBusy() {
        lock.lock();
        try {
            for (Category category : currentlyTrackedCategories) {
                if (isBusyWithAnythingIn(category)) {
                    return false;
                }
            }
            return true;
        } finally {
            lock.unlock();
        }
    }

    @Override
    public boolean isBusy() {
        return !isNotBusy();
    }

    private boolean isBusyWithAnythingIn(final Category category) {
        lock.lock();
        try {
            return !operationsInProgress.values(category).isEmpty();
        } finally {
            lock.unlock();
        }
    }

    @NonNull
    @Override
    public String toStringVerbose() {
        try {
            lock.lock();
            SetMultiMap<Category, Object> operations = operationsInProgress;
            StringBuilder sb = new StringBuilder()
                    .append("\n***********************")
                    .append("\n**BusyBee Information**")
                    .append("\n***********************");
            try {
                sb.append("\nTotal Operations:")
                        .append(operations.allValues().size())
                        .append("\nList of operations in progress:")
                        .append("\n****************************");
                for (Category category : operations.allKeys()) {
                    sb.append("\nCATEGORY: ======= ").append(category.name()).append(" =======");
                    for (Object operation : operations.values(category)) {
                        sb.append("\n").append(operation.toString());
                    }
                }
            } catch (Exception e) {
                sb.append(e.getMessage());
                sb.append("\n****!!!!FAILED TO GET LIST OF IN PROGRESS OPERATIONS!!!!****");
            }
            return sb.append("\n****************************\n").toString();
        } finally {
            lock.unlock();
        }
    }
}