package be.iminds.ilabt.jfed.experimenter_gui.timeline.model;

import be.iminds.ilabt.jfed.experiment.Experiment;
import be.iminds.ilabt.jfed.experiment.ExperimentState;
import be.iminds.ilabt.jfed.experiment.SshConnectionPool;
import be.iminds.ilabt.jfed.experiment.SshConnectionPoolFactory;
import be.iminds.ilabt.jfed.experiment.util.ExperimentUtils;
import be.iminds.ilabt.jfed.experimenter_gui.timeline.view.TimelineNodeFactory;
import be.iminds.ilabt.jfed.highlevel.model.Slice;
import be.iminds.ilabt.jfed.rspec.model.ModelRspecType;
import be.iminds.ilabt.jfed.rspec.model.controller.CommandScheduler;
import be.iminds.ilabt.jfed.rspec.model.controller.ExperimentBarrierSegment;
import be.iminds.ilabt.jfed.rspec.model.controller.ExperimentCommand;
import be.iminds.ilabt.jfed.rspec.model.javafx_impl.FXModelRspec;
import be.iminds.ilabt.jfed.rspec.model.javafx_impl.FXRspecNode;
import be.iminds.ilabt.jfed.rspec.rspec_source.ManifestRspecSource;
import be.iminds.ilabt.jfed.util.ExecuteOnNotNull;
import javafx.beans.property.*;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableBooleanValue;
import javafx.beans.value.ObservableValue;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Nullable;
import java.util.*;

/*
 * The TimelineModel will keep track of timestamps, nodes and their
 * added tests. 
 * 
 * The TimelineModel will keep a thread running for updating its time. 
 * The view will asynchronously update its timeline on a different interval
 */
public class TimelineModel {
    private static final Logger LOG = LoggerFactory.getLogger(TimelineModel.class);

    private final Experiment experiment;
    private final SshConnectionPoolFactory sshConnectionPoolFactory;

    private final SshConnectionPool sshConnectionPool;
    private final BooleanProperty stopped = new SimpleBooleanProperty(true);
    private final BooleanProperty started = new SimpleBooleanProperty(false);
    private final BooleanProperty repeating = new SimpleBooleanProperty(false);

    /**
     * Map barrierSegmentID to schedulers
     * A TreeMap will sort according to the natural ordering of its keys
     */
    private final Map<Integer, CommandScheduler> schedulers = new TreeMap<>();


    /**
     * This FXModelRspec will get node updates: nodes might be added, and login info may be added/removed
     */
    private final ObjectProperty<FXModelRspec> nodeInfoModelRspecProperty = new SimpleObjectProperty<>();

    /**
     * This FXModelRspec is to be used to store the updated commands and barriers
     */
    private final ObjectProperty<FXModelRspec> commandsModelRspecProperty = new SimpleObjectProperty<>();

    private final Map<ExperimentCommand, Thread> instantCommands = new HashMap<>();
    private final TimelineNodeFactory timelineNodeFactory;

    //The slice and experimenterModel belonging to this experiment
    private final ObjectProperty<ManifestRspecSource> manifestRspecSourceProperty = new SimpleObjectProperty<>();
    private int currentCommandID = 0;
    private int nextBarrierID = 0;
    private long startTime = 0;
    private final LongProperty elapsedTime = new SimpleLongProperty(0);
    private ExperimentBarrierSegment currentBarrierSegment;
    private final ChangeListener<Boolean> barrierSegmentCompletedListener = new ChangeListener<Boolean>() {

        @Override
        public void changed(ObservableValue<? extends Boolean> ov, Boolean t, Boolean t1) {
            if (t1) {
                currentBarrierSegment.completedProperty().removeListener(this);
                schedulers.get(currentBarrierSegment.getOrderNumber()).stopTime();
                ExperimentBarrierSegment nextBarrierSegment = getNextBarrierSegment();
                //Stop the experiment if all barrier segments have been completed
                //If there are segments left, select the next, add the listener and start it
                if (nextBarrierSegment == null) {
                    stopTime();

                    if (repeating.get()) {
                        rewindTime();
                        startTime();
                    }
                } else {
                    elapsedTime.set(0);
                    startTime = System.currentTimeMillis();
                    currentBarrierSegment = nextBarrierSegment;
                    currentBarrierSegment.completedProperty().addListener(barrierSegmentCompletedListener);
                    currentBarrierSegment.start();
                    if (!stopped.get())
                        schedulers.get(currentBarrierSegment.getOrderNumber()).startTime();
                }
            }
        }

    };
    private int instantCommandCount = 0;
    private int commandCount = 0;

    //instance initializer
    {
        started.bind(stopped.not());

        manifestRspecSourceProperty.addListener((observable) -> {
            ManifestRspecSource source = manifestRspecSourceProperty.get();
            if (source != null) {
                final FXModelRspec modelRspec = source.getModelRspec(ModelRspecType.FX);
                TimelineModel.this.nodeInfoModelRspecProperty.setValue(modelRspec);
            } else {
                TimelineModel.this.nodeInfoModelRspecProperty.setValue(null);
            }
        });
    }

    /**
     * Creates an ONLINE ControllerModel
     *
     * @param experiment               Experiment
     * @param sshConnectionPoolFactory -
     * @param timelineNodeFactory    -
     */
    public TimelineModel(Experiment experiment, SshConnectionPoolFactory sshConnectionPoolFactory, TimelineNodeFactory timelineNodeFactory) {
        this.experiment = experiment;
        this.sshConnectionPoolFactory = sshConnectionPoolFactory;
        this.timelineNodeFactory = timelineNodeFactory;

        FXModelRspec modelRspec = ExperimentUtils.getMergedRspec(experiment).getModelRspec(ModelRspecType.FX);
        this.commandsModelRspecProperty.setValue(modelRspec);
        this.nodeInfoModelRspecProperty.setValue(modelRspec); //not yet known. goOnline will set it.

        this.sshConnectionPool = sshConnectionPoolFactory.getSshConnectionPool(experiment);

        experiment.experimentStateProperty().addListener((observable, oldValue, newValue) -> {
            if (newValue == ExperimentState.EXPIRED || newValue == ExperimentState.EMPTY)
                goOffline();
        });

        new ExecuteOnNotNull<Slice>(experiment.sliceProperty()) {
            @Override
            public void run() {
                assert experiment.getSlice() != null;
                manifestRspecSourceProperty.bind(experiment.getSlice().manifestRspecProperty());
            }
        };


        //init any commands that were already present in the modelRspec
        initExistingCommands();
    }

    /**
     * Creates an OFFLINE ControllerModel
     *
     * @param modelRspec - ModelRspec to be used
     */
    public TimelineModel(FXModelRspec modelRspec, TimelineNodeFactory timelineNodeFactory) {
        this.experiment = null;
        this.sshConnectionPoolFactory = null;
        this.timelineNodeFactory = timelineNodeFactory;
        //both models are the same
        this.nodeInfoModelRspecProperty.setValue(modelRspec);
        this.commandsModelRspecProperty.bind(nodeInfoModelRspecProperty);
        this.sshConnectionPool = null;

        //init any commands that were already present in the modelRspec
        initExistingCommands();
    }    //initialize fixed listener

    private void goOffline() {
        manifestRspecSourceProperty.unbind();
        manifestRspecSourceProperty.setValue(null);
    }

    @Nullable
    public SshConnectionPool getSshConnectionPool() {
        return sshConnectionPool;
    }

    public ObservableBooleanValue allConnectableProperty() {
        return sshConnectionPool.allConnectableProperty();
    }

    public ObservableBooleanValue allConnectedProperty() {
        return sshConnectionPool.allConnectedProperty();
    }

    public ObservableBooleanValue anyConnectableProperty() {
        return sshConnectionPool.anyConnectableProperty();
    }

    public ObservableBooleanValue anyConnectedProperty() {
        return sshConnectionPool.anyConnectedProperty();
    }

    /**
     * Retrieves the commands and barriersegments from the model
     * (which might have been created from an RSpec) and adds them to this model
     */
    private void initExistingCommands() {
        FXModelRspec modelRspec = commandsModelRspecProperty.get();
        if (modelRspec == null) return;

        for (ExperimentBarrierSegment b : modelRspec.getBarrierSegments()) {
            schedulers.put(b.getOrderNumber(), new CommandScheduler(b));
            if (b.getOrderNumber() >= nextBarrierID)
                nextBarrierID = b.getOrderNumber() + 1;
        }

        if (!modelRspec.getBarrierSegments().isEmpty())
            currentBarrierSegment = modelRspec.getBarrierSegments().get(0);

        for (FXRspecNode n : modelRspec.getNodes()) {
            for (ExperimentCommand comm : n.getCommands()) {
                comm.setID(currentCommandID++);

                //// handle missing barriersegments by creating them ////
                int barrierNr = comm.getBarrierSegmentOrderNumber();
                CommandScheduler commandScheduler = schedulers.get(barrierNr);
                if (commandScheduler == null) {
                    LOG.warn("Did not find CommandScheduler with orderNr " + comm.getBarrierSegmentOrderNumber() + " will create one.");
                    ExperimentBarrierSegment barrierSegment =
                            new ExperimentBarrierSegment("Barrier segment " + barrierNr, barrierNr);
                    assert barrierSegment.getOrderNumber() == barrierNr;
                    modelRspec.addBarrierSegment(barrierSegment);

                    commandScheduler = new CommandScheduler(barrierSegment);
                    schedulers.put(barrierNr, commandScheduler);

                    if (currentBarrierSegment == null)
                        currentBarrierSegment = barrierSegment;
                }

                commandScheduler.addCommand(comm);
                comm.setSshConnectionProvider(sshConnectionPool);
                commandCount++;
            }
        }
    }

    /**
     * @return List<FXRspecNode> from modelRspec
     */
    public List<FXRspecNode> getNodes() {
        if (nodeInfoModelRspecProperty.get() == null) return Collections.emptyList();
        return nodeInfoModelRspecProperty.get().getNodes();
    }

    /**
     * Starts the experiment. It will continue from the current elapsedTime.
     */
    public void startTime() {
        stopped.set(false);
        startTime = System.currentTimeMillis() - elapsedTime.get();

        if (currentBarrierSegment == null)
            currentBarrierSegment = getNextBarrierSegment();

        if (currentBarrierSegment != null) {
            currentBarrierSegment.completedProperty().addListener(barrierSegmentCompletedListener);
            currentBarrierSegment.start();
            schedulers.get(currentBarrierSegment.getOrderNumber()).startTime();
        }
    }

    /**
     * Sets the elapsed time at the requested barrierSegment
     *
     * @param t new elapsed time
     * @param b barrier segment to set the time at
     */
    public void setElapsedTime(long t, ExperimentBarrierSegment b) {
        //Check if the time has to be restarted after setting it
        boolean restart = !stopped.get();

        //Rewind, this will stop the time and rewind to the first barrier segment, with all commands/barriers put in the unstarted state
        rewindTime();

        //Skip to the right barrier segment
        while (currentBarrierSegment != b) {
            if (currentBarrierSegment != null)
                currentBarrierSegment.skip();
            currentBarrierSegment = getNextBarrierSegment();
        }

        //Set the time
        elapsedTime.set(t);
        startTime = System.currentTimeMillis() - elapsedTime.get();

        currentBarrierSegment.completedProperty().addListener(barrierSegmentCompletedListener);
        currentBarrierSegment.start();

        schedulers.get(currentBarrierSegment.getOrderNumber()).setElapsedTime(t);

        if (restart) {
            //Start the time again
            stopped.set(false);
            schedulers.get(currentBarrierSegment.getOrderNumber()).startTime();
        }
    }

    /**
     * Stops the experiment run. It can be continued by using startTime.
     */
    public void stopTime() {
        schedulers.get(currentBarrierSegment.getOrderNumber()).stopTime();
        currentBarrierSegment.completedProperty().removeListener(barrierSegmentCompletedListener);
        stopped.set(true);
    }

    /**
     * Rewinds the experiment to the first barrier segment and to elapsed time 0.
     * If no barrier segments exist in the experiment, then the current barrier
     * segment will be null.
     */
    public void rewindTime() {
        FXModelRspec modelRspec = commandsModelRspecProperty.get();
        modelRspec.getBarrierSegments().forEach(ExperimentBarrierSegment::rewind);
        schedulers.values().forEach(CommandScheduler::rewind);

        startTime = 0;
        elapsedTime.set(0);
        stopped.set(true);

        if (currentBarrierSegment != null) {
            currentBarrierSegment.completedProperty().removeListener(barrierSegmentCompletedListener);
        }

        if (modelRspec.getBarrierSegments().isEmpty()) {
            currentBarrierSegment = null;
        } else {
            currentBarrierSegment = modelRspec.getBarrierSegments().get(0);
        }
    }

    /**
     * @return current elapsed time
     */
    public long getElapsedTime() {
        if (!stopped.get()) {
            elapsedTime.set(System.currentTimeMillis() - startTime);
        }
        return elapsedTime.get();
    }

    /**
     * Adds the given command to the experiment
     *
     * @param comm
     */
    public void addCommand(ExperimentCommand comm) {
        comm.setSshConnectionProvider(sshConnectionPool);
        comm.setID(currentCommandID++);

        schedulers.get(comm.getBarrierSegmentOrderNumber()).addCommand(comm);

        FXModelRspec modelRspec = commandsModelRspecProperty.get();
        FXRspecNode n = modelRspec.getNodeByUniqueId(comm.getNodeUniqueID());
        if (n != null) {
            n.getCommands().add(comm);
        } else {
            LOG.error("Failed to add command " + comm.getTag() + "to node " + comm.getNodeUniqueID() + ": node does not exist in modelRspec.");
        }

        ExperimentBarrierSegment b = modelRspec.getBarrierSegmentById(comm.getBarrierSegmentOrderNumber());
        if (b != null) {
            if (b.getOrderNumber() < currentBarrierSegment.getOrderNumber()) {
                comm.setSkipped(true);
                comm.setStarted(true);
                comm.setFinished(true);
            }

            b.addCommand(comm);
        } else {
            LOG.error("Failed to add command " + comm.getTag() + "to barrier segment " + comm.getBarrierSegmentOrderNumber() + ": barrier segment does not exist in modelRspec.");
        }
    }

    /**
     * A barrier segment gets added to all the nodes, with 1 unique barrierSegmentID. This ID is set by the ControllerModel.
     * The nodeID is set by the model as well, while it adds the barrier segments to the nodes.
     *
     * @param b
     */
    public void addBarrierSegment(ExperimentBarrierSegment b) {
        b.setOrderNumber(nextBarrierID++);
        if (Objects.equals(b.getTag(), ""))
            b.setTag("Barrier segment " + b.getOrderNumber());

        FXModelRspec modelRspec = commandsModelRspecProperty.get();
        modelRspec.addBarrierSegment(b);

        schedulers.put(b.getOrderNumber(), new CommandScheduler(b));

        if (currentBarrierSegment == null)
            currentBarrierSegment = modelRspec.getBarrierSegments().get(0);
    }

    /**
     * Deletes the requested barrier segment from the experiment. All the commands
     * belonging to this segment will be deleted as well.
     *
     * @param b barrier segment
     */
    public void deleteBarrierSegment(ExperimentBarrierSegment b) {
        FXModelRspec modelRspec = commandsModelRspecProperty.get();
        if (modelRspec.getBarrierSegments().contains(b)) {
            //If the current gets deleted, handle selecting + starting the new segment
            if (currentBarrierSegment == b) {
                currentBarrierSegment.completedProperty().removeListener(barrierSegmentCompletedListener);
                currentBarrierSegment = getNextBarrierSegment();

                if (!stopped.get()) {
                    if (currentBarrierSegment != null) {
                        currentBarrierSegment.completedProperty().addListener(barrierSegmentCompletedListener);
                        currentBarrierSegment.start();
                        schedulers.get(currentBarrierSegment.getOrderNumber()).startTime();
                    } else {
                        stopped.set(true);
                    }
                }
            }

            //Remove the correct segment
            modelRspec.getBarrierSegments().remove(b);
            schedulers.get(b.getOrderNumber()).stopTime();
            schedulers.get(b.getOrderNumber()).unregister();
            schedulers.remove(b.getOrderNumber());
        }
    }

    /**
     * @return The next barrier segment, or null if no next barrier segment exists
     */
    public ExperimentBarrierSegment getNextBarrierSegment() {
        FXModelRspec modelRspec = commandsModelRspecProperty.get();
        if (currentBarrierSegment == null) {
            if (modelRspec.getBarrierSegments().isEmpty())
                return null;
            else return modelRspec.getBarrierSegments().get(0);
        } else {
            int currentIndex = modelRspec.getBarrierSegments().lastIndexOf(currentBarrierSegment);
            if (currentIndex + 1 < modelRspec.getBarrierSegments().size()) {
                return modelRspec.getBarrierSegments().get(currentIndex + 1);
            } else {
                return null;
            }
        }
    }

    /**
     * @return The previous barrier segment, or null if no previous barrier segment exists
     */
    public ExperimentBarrierSegment getPreviousBarrierSegment() {
        FXModelRspec modelRspec = commandsModelRspecProperty.get();
        if (currentBarrierSegment == null) {
            if (modelRspec.getBarrierSegments().isEmpty())
                return null;
            else return modelRspec.getBarrierSegments().get(modelRspec.getBarrierSegments().size() - 1);
        } else {
            int currentIndex = modelRspec.getBarrierSegments().lastIndexOf(currentBarrierSegment);
            if (currentIndex - 1 > 0) {
                return modelRspec.getBarrierSegments().get(currentIndex - 1);
            } else {
                return null;
            }
        }
    }

    /**
     * @return commandCount +1, used for automatically assigning tags to a command
     */
    public int nextCommandNumber() {
        return ++commandCount;
    }

    /**
     * Execute a command as an instant command
     *
     * @param comm
     */
    public void instantCommand(ExperimentCommand comm) {
        comm.setSshConnectionProvider(sshConnectionPool);
        Thread t = new Thread(comm);
        t.start();

        instantCommands.put(comm, t);
    }

    /**
     * Stop an instant command
     *
     * @param command
     */
    public void stopInstantCommand(ExperimentCommand command) {
        instantCommands.get(command).interrupt();
    }

    public int nextInstantCommandNumber() {
        return ++instantCommandCount;
    }

    /**
     * Deletes the requested command from the experiment
     *
     * @param comm
     */
    public void deleteCommand(ExperimentCommand comm) {
        schedulers.get(comm.getBarrierSegmentOrderNumber()).deleteCommand(comm);

        FXModelRspec modelRspec = commandsModelRspecProperty.get();
        modelRspec.getNodeByUniqueId(comm.getNodeUniqueID()).getCommands().remove(comm);
        modelRspec.getBarrierSegmentById(comm.getBarrierSegmentOrderNumber()).deleteCommand(comm);
    }

    public ExperimentCommand getCommand(int commandID) {
        FXModelRspec modelRspec = commandsModelRspecProperty.get();
        for (FXRspecNode node : modelRspec.getNodes())
            for (ExperimentCommand comm : node.getCommands()) {
                if (comm.getID() == commandID) {
                    return comm;
                }
            }
        return null;
    }

    public FXRspecNode getRspecNode(String uniqueNodeID) {
        for (FXRspecNode n : getNodes()) {
            if (Objects.equals(n.getUniqueId(), uniqueNodeID)) {
                return n;
            }
        }
        return null;
    }

    public FXModelRspec getCommandModelRspec() {
        return commandsModelRspecProperty.get();
    }

    /**
     * Calls unregister on all the command schedulers.
     * This will make sure no changeListeners remain bound to commands.
     */
    public void unregister() {
        schedulers.values().forEach(CommandScheduler::unregister);
    }

    public BooleanProperty stoppedProperty() {
        return stopped;
    }

    public boolean isStopped() {
        return stopped.get();
    }

    /**
     * @return a boolean representing the running state of the experiment
     */
    public boolean isStarted() {
        return started.get();
    }

    public BooleanProperty startedProperty() {
        return started;
    }

    public boolean getRepeating() {
        return repeating.get();
    }

    public void setRepeating(boolean repeating) {
        this.repeating.set(repeating);
    }

    public BooleanProperty repeatingProperty() {
        return repeating;
    }

    public LongProperty elapsedTimeProperty() {
        return elapsedTime;
    }

    public boolean isOfflineEditor() {
        return experiment == null;
    }

    public TimelineNodeFactory getTimelineNodeFactory() {
        return timelineNodeFactory;
    }

    public Experiment getExperiment() {
        return experiment;
    }
}
