package be.iminds.ilabt.jfed.rspec.model.controller;

import javafx.application.Platform;
import javafx.beans.property.*;
import net.schmizz.sshj.SSHClient;
import net.schmizz.sshj.connection.ConnectionException;
import net.schmizz.sshj.connection.channel.direct.Session;
import net.schmizz.sshj.transport.TransportException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.xml.bind.annotation.*;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

/**
 * @author Cedric
 */
@XmlAccessorType(XmlAccessType.NONE)
@XmlRootElement(namespace = "http://jfed.iminds.be/rspec/ext/jfed-command/1")
public class ExperimentCommand implements Runnable {

    private static final Logger LOG = LoggerFactory.getLogger(ExperimentCommand.class);

    private int ID, barrierSegmentOrderNumber;
    private final StringProperty tag, nodeUniqueId, /*nodeHostname,*/
            command;
    private Timestamp startingTime, duration;
    private final DoubleProperty start, dur;

    private SshConnectionProvider sshConnectionProvider;

    private Session.Command currentSession;

    private final StringProperty result, err;
    private final List<ExperimentCommandOutput> outputCollection = new ArrayList<>();
    //Skipped = a command that is not executed, by changing the elapsed time beyond this command
    private final BooleanProperty started, finished, skipped;

    /**
     * @param tag              the tag of the command, chosen by the user. Must be unique for the node.
     * @param nodeUniqueId     the ID of the node to which the command belongs
     * @param barrierSegmentID
     * @param command          a command line command
     * @param time             Timestamp
     * @param duration         Timestamp
     */
    public ExperimentCommand(String tag, String nodeUniqueId, int barrierSegmentID, String command, Timestamp time, Timestamp duration) {
        assert tag != null : "tag may not be null";
        assert !tag.isEmpty() : "tag may not be an empty string";
        assert nodeUniqueId != null : "nodeUniqueId may not be null";
        assert !nodeUniqueId.isEmpty() : "nodeUniqueId may not be an empty string";
        assert nodeUniqueId.contains("@") : "nodeUniqueId is probably not a uniqueId: \"" + nodeUniqueId + "\"";
        assert barrierSegmentID >= 0;

        this.tag = new SimpleStringProperty(tag);
        this.nodeUniqueId = new SimpleStringProperty(nodeUniqueId);
        //this.nodeHostname = new SimpleStringProperty(nodeHostname);
        this.barrierSegmentOrderNumber = barrierSegmentID;
        this.command = new SimpleStringProperty(command);
        this.startingTime = time;
        this.duration = duration;
        this.start = new SimpleDoubleProperty(startingTime.toMillis());
        this.dur = new SimpleDoubleProperty(duration.toMillis());
        this.started = new SimpleBooleanProperty(false);
        this.finished = new SimpleBooleanProperty(false);
        this.skipped = new SimpleBooleanProperty(false);
        this.result = new SimpleStringProperty("");
        this.err = new SimpleStringProperty("");
    }

    public ExperimentCommand(ExperimentCommand experimentCommand) {
        this(experimentCommand.getTag(), experimentCommand.getNodeUniqueID(), experimentCommand.getBarrierSegmentOrderNumber(),
                experimentCommand.getCommand(), experimentCommand.getStartingTime(), experimentCommand.getDuration());

        assert nodeUniqueId.get() != null : "nodeUniqueId may not be null";
        assert !nodeUniqueId.get().isEmpty() : "nodeUniqueId may not be an empty string";
        assert nodeUniqueId.get().contains("@") : "nodeUniqueId is probably not a uniqueId: \"" + nodeUniqueId + "\"";
    }

    /**
     * for JAXB use only!
     */
    private ExperimentCommand() {
        //this("fakeTagDueToJaxB", "fakeUniqueIdDueToJaxB", "", 0, "", new Timestamp(0), new Timestamp(0));

        this.tag = new SimpleStringProperty(null);
        this.nodeUniqueId = new SimpleStringProperty(null);
        //this.nodeHostname = new SimpleStringProperty(null);
        this.barrierSegmentOrderNumber = -1;
        this.command = new SimpleStringProperty(null);
        this.startingTime = new Timestamp(0);
        this.duration = new Timestamp(0);
        this.start = new SimpleDoubleProperty(0.0);
        this.dur = new SimpleDoubleProperty(0.0);
        this.started = new SimpleBooleanProperty(false);
        this.finished = new SimpleBooleanProperty(false);
        this.skipped = new SimpleBooleanProperty(false);
        this.result = new SimpleStringProperty("");
        this.err = new SimpleStringProperty("");
    }

    /**
     * PRE-conditions:
     * > started = false
     * > finished = false
     * > currentSession = null
     * == a new command or a command that has called reset()
     * <p/>
     * Can be interrupted! This runnable will check for interruption while waiting for/retrieving output from the session.
     */
    @Override
    public void run() {
        ExperimentCommandOutput output = new ExperimentCommandOutput();
        output.setNodeID(nodeUniqueId.get());
        output.setCommandTag(tag.get());
        output.setCommand(command.get());

        SSHClient sshConnection =
                sshConnectionProvider.getConnection(nodeUniqueId.get(), BlockingMode.WAIT_FOR_SINGLE);

        if (sshConnection == null) {
            Platform.runLater(() -> {
                started.set(true);
                err.set("Unable to execute command: connection is null.\n");
                finished.set(true);
            });

            output.setOutput("");
            output.setError(err.get());
            output.stop();
            outputCollection.add(output);

            return;
        }

        try {
            //TODO: set to real hostname of SSH Host
            output.setNodeHostname(nodeUniqueId.get());//sshClient.getHostname());

            Platform.runLater(() -> started.set(true));

            output.start();

            long localStart = System.currentTimeMillis();

            currentSession = sshConnection.startSession().exec(command.get());

            Thread outputThread = new Thread(() -> {
                try {
                    while (true) {
                        byte[] lineOut = new byte[200];
                        int bytesRead = currentSession.getInputStream().read(lineOut);
                        if (bytesRead > 0) {
                            byte[] finalOutput = Arrays.copyOf(lineOut, bytesRead);
                            String outputString = new String(finalOutput, "UTF-8");
                            while (outputString.contains("\n")) {
                                synchronized (result) {
                                    result.set(result.get() + outputString.substring(0, outputString.indexOf("\n")) + System.lineSeparator());
                                }
                                outputString = outputString.substring(outputString.indexOf("\n") + 1);
                            }
                            if (!outputString.isEmpty()) {
                                synchronized (result) {
                                    result.set(result.get() + outputString + System.lineSeparator());
                                }
                            }
                        } else {
                            return;
                        }
                    }
                } catch (Exception | AssertionError ex) {
                    LOG.error("Caught unhandled throwable in experimenter command out thread", ex);
                    return;
                }
            });

            Thread errThread = new Thread(() -> {
                try {
                    while (true) {
                        byte[] lineErr = new byte[200];
                        int bytesRead = currentSession.getErrorStream().read(lineErr);
                        if (bytesRead > 0) {
                            byte[] finalOutput = Arrays.copyOf(lineErr, bytesRead);
                            String outputString = new String(finalOutput, "UTF-8");
                            while (outputString.contains("\n")) {
                                synchronized (err) {
                                    err.set(err.get() + outputString.substring(0, outputString.indexOf("\n")) + System.lineSeparator());
                                }
                                outputString = outputString.substring(outputString.indexOf("\n") + 1);
                            }
                            if (!outputString.isEmpty()) {
                                synchronized (err) {
                                    err.set(err.get() + outputString + System.lineSeparator());
                                }
                            }
                        } else {
                            return;
                        }
                    }
                } catch (Exception ex) {
                    LOG.error("Caught unhandled throwable in experimenter command err thread", ex);
                    return;
                }
            });

            outputThread.start();
            errThread.start();

            outputThread.join();
            errThread.join();

            //Save the output, close the session and set the duration of the command.

            output.setOutput(result.get());
            output.setError(err.get());

            //Check for null, in case command.reset() has been executed while this run was happening
            if (currentSession != null) {
                currentSession.close();
                currentSession = null;
            }

            output.stop();
            outputCollection.add(output);

            final long newDuration = System.currentTimeMillis() - localStart;

            Platform.runLater(() -> {
                setDuration(new Timestamp(newDuration));
                finished.set(true);
            });

        } catch (InterruptedException iex) {
            err.set("This command got interrupted before it was completed.\n");

            if (currentSession != null) {
                try {
                    currentSession.close();
                } catch (TransportException | ConnectionException ignored) {
                }
                currentSession = null;
            }
            finished.set(true);
        } catch (Exception ex) {
            LOG.error(ex.getMessage());

            err.set("Something went wrong while executing the command. Please see the logs for more details.\n");

            if (currentSession != null) {
                try {
                    currentSession.close();
                } catch (TransportException | ConnectionException ignored) {
                }
                currentSession = null;
            }
            finished.set(true);
        }

    }

    public List<ExperimentCommandOutput> getOutputCollection() {
        return outputCollection;
    }

    public int getID() {
        return ID;
    }

    /**
     * @param ID the ID of the command, used for keeping track of it internally. This ID is assigned to it by the ControllerModel.
     */
    @XmlTransient
    public void setID(int ID) {
        this.ID = ID;
    }

    public int getBarrierSegmentOrderNumber() {
        return barrierSegmentOrderNumber;
    }

    @XmlAttribute
    public void setBarrierSegmentOrderNumber(int barrierSegmentOrderNumber) {
        this.barrierSegmentOrderNumber = barrierSegmentOrderNumber;
    }

    public String getTag() {
        return tag.get();
    }

    @XmlAttribute
    public void setTag(String tag) {
        this.tag.set(tag);
    }

    public String getNodeUniqueID() {
        return nodeUniqueId.get();
    }

    @XmlTransient
    public void setNodeUniqueID(String nodeID) {
        this.nodeUniqueId.set(nodeID);

        assert nodeUniqueId.get() != null : "nodeUniqueId may not be null";
        assert !nodeUniqueId.get().isEmpty() : "nodeUniqueId may not be an empty string";
        assert nodeUniqueId.get().contains("@") : "nodeUniqueId is probably not a uniqueId: \"" + nodeUniqueId + "\"";
    }

//    public String getNodeHostname(){
//        return nodeHostname.get();
//    }
//
//    @XmlTransient
//    public void setNodeHostname(String nodeHostname){
//        this.nodeHostname.set(nodeHostname);
//    }

    public String getCommand() {
        return command.get();
    }

    @XmlElement
    public void setCommand(String comm) {
        command.set(comm);
    }

    public SshConnectionProvider getSshConnectionProvider() {
        return sshConnectionProvider;
    }

    @XmlTransient
    public void setSshConnectionProvider(SshConnectionProvider cp) {
        this.sshConnectionProvider = cp;
    }

    public Timestamp getStartingTime() {
        synchronized (this) {
            return startingTime;
        }
    }

    @XmlElement
    public void setStartingTime(Timestamp newStartingTime) {
        synchronized (this) {
            startingTime = newStartingTime;
            start.set(newStartingTime.toMillis());
        }
    }

    @XmlElement
    public void setDuration(Timestamp newDuration) {
        synchronized (this) {
            duration = newDuration;
            dur.set(newDuration.toMillis());
        }
    }

    public Timestamp getDuration() {
        synchronized (this) {
            return duration;
        }
    }

    public boolean isStarted() {
        synchronized (this) {
            return started.get();
        }
    }

    @XmlTransient
    public void setStarted(boolean started) {
        synchronized (this) {
            this.started.set(started);
        }
    }

    public boolean isFinished() {
        synchronized (this) {
            return finished.get();
        }
    }

    @XmlTransient
    public void setFinished(boolean finished) {
        synchronized (this) {
            this.finished.set(finished);
        }
    }

    public boolean isSkipped() {
        synchronized (this) {
            return skipped.get();
        }
    }

    @XmlTransient
    public void setSkipped(boolean skipped) {
        synchronized (this) {
            this.skipped.set(skipped);
        }
    }

    public void resetCommand() {
        if (currentSession != null) {
            try {
                currentSession.close();
            } catch (TransportException | ConnectionException ignored) {
            }
            currentSession = null;
        }

        started.set(false);
        finished.set(false);
        skipped.set(false);

        result.set("");
        err.set("");

    }

    public StringProperty getTagProperty() {
        return tag;
    }

    public StringProperty getNodeIDProperty() {
        return nodeUniqueId;
    }

    public StringProperty getCommandProperty() {
        return command;
    }

    public DoubleProperty getStartingTimeProperty() {
        return start;
    }

    public DoubleProperty getDurationProperty() {
        return dur;
    }

    public BooleanProperty getStartedProperty() {
        return started;
    }

    public BooleanProperty getFinishedProperty() {
        return finished;
    }

    public StringProperty getResultProperty() {
        return result;
    }

    public StringProperty getErrorProperty() {
        return err;
    }

//    public StringProperty getNodeHostnameProperty(){
//        return nodeHostname;
//    }


    public enum BlockingMode {
        NON_BLOCKING,     //return null if connection not readu
        WAIT_FOR_SINGLE,  //only return if the requested connection is ready
        WAIT_FOR_ALL
    }

    //only return if the requested connection, AND all other relevant connections are ready

    public interface SshConnectionProvider {
        /**
         * @param nodeUniqueId
         * @param blockingMode
         * @return
         */
        SSHClient getConnection(String nodeUniqueId, BlockingMode blockingMode);
    }
}
