package be.iminds.ilabt.jfed.experiment;

import be.iminds.ilabt.jfed.experiment.events.*;
import be.iminds.ilabt.jfed.experiment.util.NextExperimentExpiration;
import be.iminds.ilabt.jfed.experiment.util.NextExperimentExpirationBinding;
import be.iminds.ilabt.jfed.fedmon.webapi.service.json.Server;
import be.iminds.ilabt.jfed.fedmon.webapi.service.json.Service;
import be.iminds.ilabt.jfed.highlevel.jobs.Job;
import be.iminds.ilabt.jfed.highlevel.jobs.JobFactory;
import be.iminds.ilabt.jfed.highlevel.model.InternalState;
import be.iminds.ilabt.jfed.highlevel.model.Sliver;
import be.iminds.ilabt.jfed.lowlevel.GeniUserProvider;
import be.iminds.ilabt.jfed.lowlevel.TestbedInfoSource;
import be.iminds.ilabt.jfed.lowlevel.api.user_spec.UserSpec;
import be.iminds.ilabt.jfed.preferences.JFedCorePreferences;
import be.iminds.ilabt.jfed.rspec.model.javafx_impl.FXRspecLink;
import be.iminds.ilabt.jfed.rspec.model.javafx_impl.FXRspecNode;
import be.iminds.ilabt.jfed.util.FXPlatformUtil;
import be.iminds.ilabt.jfed.util.GeniUrn;
import com.google.common.util.concurrent.*;
import javafx.application.Platform;
import javafx.stage.Window;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.concurrent.*;

/**
 * This class will take care of an Experiment-object. It makes sure that the appropriate actions are taken when
 * the state of the Slice, or one of it's slivers changes.
 * <p/>
 * User: twalcari
 * Date: 1/30/2015
 * Time: 14:04
 */
public class ExperimentController implements ExperimentChangeListener {
    private static final Logger LOG = LoggerFactory.getLogger(ExperimentController.class);
    private final Experiment experiment;
    private final ExperimentEventHandlerManager eventHandlerManager = new ExperimentEventHandlerManager();

    private final JobFactory jobFactory;
    private final TestbedInfoSource testbedInfoSource;
    private final JFedCorePreferences jFedPreferences;
    private final GeniUserProvider geniUserProvider;

    private static final ExecutorService executorService = Executors.newCachedThreadPool(
            new ThreadFactoryBuilder().setNameFormat("ExperimentController-%d").build()
    );
    private final ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(2,
            new ThreadFactoryBuilder().setNameFormat("ExperimentController-Scheduled-%d").build());


    private static final ListeningExecutorService jobExecutorService =
            MoreExecutors.listeningDecorator(Executors.newCachedThreadPool(
                    new ThreadFactoryBuilder().setNameFormat("JobExecutor-%d").build()));

    private Window parentWindow = null;
    //helper variable for showing dialog messages. Ideally, this should be refactored away

    private final List<ExperimentControllerListener> listeners = new ArrayList<>();
    private final ExpirationDetector expirationDetector;

    private ExperimentTester experimentTester = null;

    public ExperimentController(Experiment experiment, JobFactory jobFactory, TestbedInfoSource testbedInfoSource, JFedCorePreferences jFedPreferences, GeniUserProvider geniUserProvider) {
        this.experiment = experiment;
        this.jobFactory = jobFactory;
        this.testbedInfoSource = testbedInfoSource;
        this.jFedPreferences = jFedPreferences;
        this.geniUserProvider = geniUserProvider;

        this.expirationDetector = new ExpirationDetector();

        experiment.addExperimentChangeListener(this);
    }

    /**
     * This method must be called for the experimentController to start taking actions.
     */
    public void start() {
        //bootstrap state machine
        LOG.debug("Bootstrapping experiment controller of '{}' in state {}", experiment.getName(), experiment.getExperimentState());
        onExperimentStateChange(experiment.getExperimentState());
    }


    @Override
    public void onExperimentStateChange(ExperimentState newExperimentState) {
        switch (newExperimentState) {
            case PENDING:
                createSlice();
                break;
            case RESTORING:
                restoreSlice();
                break;
            case CREATED:
                //startSliceExpirationDetection();
                if (experiment.getNewRequestRspecSource() != null) {
                    if (experiment.getRequestedStartTime() != null) {
                        if (!experiment.isReservationMade()) {
                            experiment.setExperimentState(ExperimentState.ALLOCATING);
                        } else if (experiment.getRequestedStartTime().isAfter(Instant.now())) {
                            experiment.setExperimentState(ExperimentState.FUTURE_RESERVATION);
                        } else {
                            // the reservation allocation was already done, and the start time has passed, so start provisioning
                            experiment.setExperimentState(ExperimentState.PROVISIONING);
                        }
                    } else {
                        experiment.setExperimentState(ExperimentState.ALLOCATING);
                    }
                } else {
                    if (experiment.getRequestedStartTime() != null
                            && experiment.getRequestedStartTime().isAfter(Instant.now())) {
                        experiment.setExperimentState(ExperimentState.FUTURE_RESERVATION);
                    } else {
                        experiment.setExperimentState(ExperimentState.TESTING);
                    }
                }
                break;
            case ALLOCATING:
                allocateResources();
                break;
            case FUTURE_RESERVATION:
                waitForReservationStart();
                break;
            case PROVISIONING:
                provisionResources();
                break;
            case TESTING:
                testSliversConnectivity();
                break;
            case READY:
                break;
            case FAILING:
                break;
            case FAILED:
                break;
            case EXPIRING:
                checkForExpiration();
                break;
            case EXPIRED:
            case EMPTY:
                deleteRestoreInformation();
                break;
        }


    }

    private void deleteRestoreInformation() {
        experiment.getExperimentRestoreInformation().delete();
    }

    @Override
    public void onExperimentPartAdded(ExperimentPart experimentPart) {
        //do nothing
    }

    private void checkForExpiration() {
        requestUpdate();
    }

    private void checkExperimentPartsStates() {
        boolean allPartsDeletedOrExpired = experiment.getParts().stream()
                .allMatch(ep -> ep.getState().equals(InternalState.DELETED) || ep.getState().equals(InternalState.UNALLOCATED));

        if (allPartsDeletedOrExpired) {
            if (experiment.getSlice() != null
                    && experiment.getSlice().getExpirationDate() != null
                    && experiment.getSlice().getExpirationDate().isBefore(Instant.now()))
                experiment.setExperimentState(ExperimentState.EXPIRED);
            else
                experiment.setExperimentState(ExperimentState.EMPTY);
        } else {
            experiment.setExperimentState(ExperimentState.READY);
        }
    }


    private void testSliversConnectivity() {
        if (experimentTester != null) {
            Future<Boolean> future = submitJob(experimentTester.createTestExperimentJob(experiment));

            Thread testWaiterThread = new Thread(null, () -> {
                try {
                    future.get();
                } catch (InterruptedException | ExecutionException e) {
                    LOG.error("Error while waiting for ExperimentTester of '{}' to finish: {}",
                            experiment.getName(), e.getMessage(), e);
                }

                deriveAndSetExperimentStateAfterTesting();

            }, "Wait-For-Tester-" + experiment.getName());
            testWaiterThread.start();
        } else {
            LOG.info("Skipping testing of experiment, as no ExperimentTester is registered.");
            deriveAndSetExperimentStateAfterTesting();
        }
    }

    private void deriveAndSetExperimentStateAfterTesting() {
        assert experiment.getExperimentState() == ExperimentState.TESTING;

        NextExperimentExpiration nextExperimentExpiration = new NextExperimentExpiration(experiment);
        if (nextExperimentExpiration.hasPartExpired()) {
            experiment.setExperimentState(ExperimentState.EXPIRING);
            return;
        }

        boolean allReady = true;
        boolean allEmpty = true;

        for (ExperimentPart part : experiment.getParts()) {
            allReady = allReady && part.getState() == InternalState.READY;
            allEmpty = allEmpty && part.getState() == InternalState.UNALLOCATED;
        }

        if (allReady)
            experiment.setExperimentState(ExperimentState.READY);
        else if (allEmpty)
            experiment.setExperimentState(ExperimentState.EMPTY);

        else {
            LOG.warn("Could not determine a valid experiment state for {}. Defaulting to READY", experiment.getName());
            experiment.setExperimentState(ExperimentState.READY);
        }

    }


    public void createSlice() {
        //we must initiate the creation of the slice for this experiment
        assert (experiment.getRequestedEndTime() != null);

        submitJob(jobFactory.createRegisterExperimentJob(experiment));
    }

    private void restoreSlice() {
        submitJob(jobFactory.createRestoreExperimentJob(experiment));
    }

    private void allocateResources() {
        experiment.getSlice().setRequestRspec(experiment.getNewRequestRspecSource());

        ListenableFuture<Boolean> future =
                submitJob(jobFactory.createAllocateExperimentJob(experiment, getScsAuthority()));

        Futures.addCallback(future, new FutureCallback<Boolean>() {
            @Override
            public void onSuccess(@Nullable Boolean success) {
                if (success == Boolean.TRUE) {
                    if (experiment.getRequestedStartTime() == null || experiment.getRequestedStartTime().isBefore(Instant.now())) {
                        experiment.setExperimentState(ExperimentState.PROVISIONING);
                    } else {
                        experiment.setExperimentState(ExperimentState.FUTURE_RESERVATION);
                    }
                } else {
                    LOG.debug("AllocateExperimentJob for {} succeeded but returned success={}", experiment.getName(), success);
                    experiment.setExperimentState(ExperimentState.FAILING);
                }
            }

            @Override
            public void onFailure(Throwable throwable) {
                LOG.error("AllocateExperimentJob for " + experiment.getName() + " failed: ", throwable);
                experiment.setExperimentState(ExperimentState.FAILING);
            }
        }, FXPlatformUtil.getExecutor());
    }


    private void waitForReservationStart() {
        assert (experiment.getRequestedStartTime() != null);

        Duration durationToStartTime = Duration.between(Instant.now(), experiment.getRequestedStartTime());

        Runnable startProvisioning = () -> experiment.setExperimentState(ExperimentState.PROVISIONING);

        if (!durationToStartTime.isNegative()) {


            scheduledExecutorService.schedule(startProvisioning, durationToStartTime.getSeconds() + 1, TimeUnit.SECONDS);
        } else {
            startProvisioning.run();
        }
    }

    public ListenableFuture<Void> requestUpdate() {
        final ListenableFuture<Void> future = submitJob(jobFactory.createUpdateExperimentJob(this.getExperiment()));
        Futures.addCallback(future, new FutureCallback<Void>() {
            @Override
            public void onSuccess(@Nullable Void aVoid) {
                checkExperimentPartsStates();
            }

            @Override
            public void onFailure(@Nonnull Throwable throwable) {
                LOG.warn("Update experiment failed: {}. Checking for experiment part states anyway.", throwable.getMessage(), throwable);
                checkExperimentPartsStates();
            }
        }, Platform::runLater);
        return future;
    }

    public void shareWithUsers(List<GeniUrn> selectedUsers, boolean registerSshKeys) {
        submitJob(jobFactory.createShareSliceJob(experiment, selectedUsers, registerSshKeys));
    }

    public void updateSshKeys(List<UserSpec> userspec) {
        submitJob(jobFactory.createEditSshKeysJob(experiment, userspec));
    }

    public void reloadOS(Sliver sliver) {
        submitJob(jobFactory.createReloadOSJob(experiment, sliver));
    }

    public void createDiskImage(FXRspecNode node, String imageName, boolean global, boolean updatePrepare) {
        submitJob(jobFactory.createCreateDiskImageJob(experiment, node, imageName, global, updatePrepare));
    }

    public void stop(Collection<ExperimentPart> experimentParts) {
        final ListenableFuture<Void> future =
                submitJob(jobFactory.createStopExperimentJob(experiment, experimentParts));

        Futures.addCallback(future, new FutureCallback<Void>() {
            @Override
            public void onSuccess(@Nullable Void aVoid) {
                checkExperimentPartsStates();
            }

            @Override
            public void onFailure(@Nonnull Throwable throwable) {
                LOG.warn("Stopping experiment failed: {}. Checking experiment part states anyway.",
                        throwable.getMessage(), throwable);
                checkExperimentPartsStates();
            }
        });

        eventHandlerManager.dispatch(new ExperimentPartsEvent(this, ExperimentPartsEvent.STOPPED, experimentParts));
    }

    public void stop() {
        stop(experiment.getParts());
    }

    public void reboot(Sliver sliver) {
        submitJob(jobFactory.createRebootJob(experiment, sliver));
    }

    public void reboot(ExperimentPart experimentPart) {
        assert (experiment.getParts().contains(experimentPart));
        submitJob(jobFactory.createRebootJob(experimentPart));
    }

    public void openConsole(Sliver sliver) {
        submitJob(jobFactory.createOpenConsoleJob(experiment, sliver));
    }

    public void shareLan(FXRspecLink link, String sharedLanName) {
        submitJob(jobFactory.createShareLanJob(experiment, link, sharedLanName));
    }

    public void unshareLan(FXRspecLink link, String sharedLanName) {
        submitJob(jobFactory.createUnshareLanJob(experiment, link, sharedLanName));
    }

    public ListenableFuture<Collection<UserSpec>> fetchSliceMemberSshKeys() {
        return submitJob(jobFactory.createFetchSliceMemberSshKeysJob(experiment));
    }

    public void renew(Instant newExpirationTime) {
        submitJob(jobFactory.createRenewJob(experiment, newExpirationTime));
    }

    public void renewParts(Instant newExpirationTime, Collection<ExperimentPart> parts) {
        submitJob(jobFactory.createRenewJob(experiment, newExpirationTime, parts));
    }


    private class ExpirationDetector implements Runnable {

        private final NextExperimentExpirationBinding nextExperimentExpirationBinding;
        private ScheduledFuture<?> previousScheduledExpiration = null;

        public ExpirationDetector() {
            this.nextExperimentExpirationBinding = new NextExperimentExpirationBinding(experiment);
            this.nextExperimentExpirationBinding.addListener(observable -> scheduleForNextExpiration());
        }


        @Override
        public void run() {
            final NextExperimentExpiration nextExpiration = nextExperimentExpirationBinding.get();
            if (nextExpiration.hasPartExpired()) {
                LOG.trace("A part of the experiment {} has expired!", experiment.getName());
                //(a part of) the experiment seems to have expired!
                experiment.setExperimentState(ExperimentState.EXPIRING);
            } else {
                scheduleForNextExpiration();
            }
        }

        private void scheduleForNextExpiration() {
            if (previousScheduledExpiration != null)
                previousScheduledExpiration.cancel(false);

            final NextExperimentExpiration nextExpiration = nextExperimentExpirationBinding.get();

            if (nextExpiration.getFirstExpirationTime() != null) {
                Duration durationToFirstExpiration =
                        Duration.between(Instant.now(), nextExpiration.getFirstExpirationTime());

                assert !durationToFirstExpiration.isNegative();

                LOG.debug("Scheduling next expiration check for {} in {} seconds", experiment.getName(), durationToFirstExpiration.getSeconds() + 1);

                previousScheduledExpiration =
                        scheduledExecutorService.schedule(this, durationToFirstExpiration.getSeconds() + 1, TimeUnit.SECONDS);
            } else if (!nextExpiration.hasPartExpired()) {
                LOG.debug("Could not find an expiration time. Trying again in 10 seconds");
                previousScheduledExpiration = scheduledExecutorService.schedule(this, 10, TimeUnit.SECONDS);
            }
        }
    }

    private void provisionResources() {
        assert experiment.getNewRequestRspecSource() != null;
        assert experiment.getSlice() != null;

        experiment.getSlice().setRequestRspec(experiment.getNewRequestRspecSource());

        ListenableFuture<Boolean> future =
                submitJob(jobFactory.createProvisionExperimentJob(experiment, false));

        Futures.addCallback(future, new FutureCallback<Boolean>() {
            @Override
            public void onSuccess(@Nullable Boolean success) {
                if (success == Boolean.TRUE)
                    experiment.setExperimentState(ExperimentState.TESTING);
                else {
                    LOG.debug("StartStitchedExperimentJob for {} succeeded but returned success={}", experiment.getName(), success);
                    experiment.setExperimentState(ExperimentState.FAILING);
                }
            }

            @Override
            public void onFailure(@Nonnull Throwable throwable) {
                LOG.error("StartStitchedExperimentJob for " + experiment.getName() + " failed: ", throwable);
                experiment.setExperimentState(ExperimentState.FAILING);
            }
        }, FXPlatformUtil.getExecutor());

    }

    private Server getScsAuthority() {
        Server scsServer = jFedPreferences.getScsAuthority(testbedInfoSource);
        if (scsServer == null) {
            Integer scsId = geniUserProvider.getLoggedInGeniUser().getUserAuthorityServer().getDefaultScsId();
            if (scsId != null) {
                Service scsService = testbedInfoSource.getServiceById(scsId);
                assert scsService != null : "Could not find scs " + scsId;
                if (scsService != null) {
                    scsServer = scsService.getServer();
                    assert scsServer != null;
//                  GeniUrn defaultScsUrn = scsServer.getDefaultComponentManagerAsGeniUrn();
                }
            }
        }
        return scsServer;
    }

    private <V> ListenableFuture<V> submitJob(Job<V> job) {

        listeners.forEach(listener -> listener.onJobSubmitted(job));

        job.stateProperty().addListener(observable ->
                listeners.forEach(listener -> listener.onJobStateChanged(job, job.getState()))
        );

        return jobExecutorService.submit(() -> {
            try {
                V result = job.call();

                listeners.forEach(listener -> listener.onJobFinished(job));

                return result;
            } catch (Exception ex) {
                LOG.error("Exception while executing {}: {}", job.getName(), ex.getMessage(), ex);
                throw ex;
            }
        });

    }

    public Experiment getExperiment() {
        return experiment;
    }

    public Window getParentWindow() {
        return parentWindow;
    }

    public void setParentWindow(Window parentWindow) {
        this.parentWindow = parentWindow;
    }

    public void addListener(ExperimentControllerListener listener) {
        this.listeners.add(listener);
    }

    public void removeListener(ExperimentControllerListener listener) {
        this.listeners.remove(listener);
    }

    public ExperimentTester getExperimentTester() {
        return experimentTester;
    }

    public void setExperimentTester(ExperimentTester experimentTester) {
        this.experimentTester = experimentTester;
    }

    public <T extends ExperimentEvent> void addEventHandler(
            ExperimentEventType<T> type, ExperimentEventHandler<T> eventHandler) {
        eventHandlerManager.addExperimentEventHandler(type, eventHandler);
    }
}
