/*
 * Created on Feb 10, 2005
 *
 */
package fi.csc.microarray.client.tasks;

import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.beans.PropertyChangeEvent;
import java.beans.PropertyChangeListener;
import java.io.IOException;
import java.util.Collection;
import java.util.Date;
import java.util.LinkedList;
import java.util.List;

import javax.jms.JMSException;
import javax.swing.SwingUtilities;
import javax.swing.Timer;
import javax.swing.event.SwingPropertyChangeSupport;

import org.apache.log4j.Logger;

import fi.csc.chipster.tools.ngs.LocalNGSPreprocess;
import fi.csc.microarray.client.ClientApplication;
import fi.csc.microarray.client.Session;
import fi.csc.microarray.client.operation.OperationRecord;
import fi.csc.microarray.client.operation.OperationRecord.InputRecord;
import fi.csc.microarray.client.tasks.Task.State;
import fi.csc.microarray.databeans.DataBean;
import fi.csc.microarray.databeans.DataManager;
import fi.csc.microarray.exception.MicroarrayException;
import fi.csc.microarray.filebroker.NotEnoughDiskSpaceException;
import fi.csc.microarray.messaging.JobState;
import fi.csc.microarray.messaging.MessagingEndpoint;
import fi.csc.microarray.messaging.MessagingTopic;
import fi.csc.microarray.messaging.MessagingTopic.AccessMode;
import fi.csc.microarray.messaging.TempTopicMessagingListener;
import fi.csc.microarray.messaging.TempTopicMessagingListenerBase;
import fi.csc.microarray.messaging.Topics;
import fi.csc.microarray.messaging.message.ChipsterMessage;
import fi.csc.microarray.messaging.message.CommandMessage;
import fi.csc.microarray.messaging.message.JobMessage;
import fi.csc.microarray.messaging.message.ParameterMessage;
import fi.csc.microarray.messaging.message.ResultMessage;
import fi.csc.microarray.util.Exceptions;
import fi.csc.microarray.util.IOUtils.CopyProgressListener;

/**
 * Allows easy management of local or remote tasks submitted through JMS and acts as a mediator between Swing Event Dispatch Thread and
 * other threads.
 * 
 * @author Aleksi Kallio
 * 
 */
public class TaskExecutor {
	/**
	 * Logger for this class
	 */

	private static final Logger logger = Logger.getLogger(TaskExecutor.class);
	private DataManager manager;

	private MessagingTopic requestTopic;
	private LinkedList<Task> tasks = new LinkedList<Task>();
	private LinkedList<Task> runningTasks = new LinkedList<Task>();
	private SwingPropertyChangeSupport jobExecutorStateChangeSupport;
	private boolean eventsEnabled = false;

	private class TimeoutListener implements ActionListener {
		Task taskToMonitor;

		TimeoutListener(Task taskToMonitor) {
			this.taskToMonitor = taskToMonitor;
		}

		public void actionPerformed(ActionEvent e) {
			synchronized (taskToMonitor) {
				if (!taskToMonitor.getState().isFinished()) {
					updateTaskState(taskToMonitor, State.TIMEOUT, null, -1);
				}
			}

			removeFromRunningTasks(taskToMonitor);

			// send the cancel message
			sendCancelMessage(taskToMonitor);
		}
	};

	/**
	 * Encapsulates notification to make it passable to Event Dispatch Thread.
	 */
	private class TaskExecutorChangeNotifier implements Runnable {

		private TaskExecutor parent;

		public TaskExecutorChangeNotifier(TaskExecutor parent) {
			this.parent = parent;
		}

		public void run() {
			dispatch(new PropertyChangeEvent(parent, "runningJobCount", null, getRunningTaskCount()));
		}
	}

	private enum ResultListenerState {
		WAIT_FOR_STATUS, FINISHED, TIMEOUT
	};

	/**
	 * For listening to temporary result Topics.
	 *
	 */
	private class ResultMessageListener extends TempTopicMessagingListenerBase {

		Task pendingTask; 
		// should be removed
		ResultListenerState internalState;

		/**
		 * @param pendingTask
		 * @param taskEventListener
		 */
		public ResultMessageListener(Task pendingTask) {
			this.pendingTask = pendingTask;
			this.internalState = ResultListenerState.WAIT_FOR_STATUS;
		}

		public void onChipsterMessage(ChipsterMessage msg) {
			logger.debug("Task " + pendingTask.getId() + " got message (" + msg.getMessageID() + ") of type " + msg.getClass().getName());
			
			// ignore results that don't belong to this session
			synchronized (runningTasks) {
				if (!runningTasks.contains(pendingTask)) {
					return;
				}
			}

			// ignore everything if we (ResultListener) are already finished
			if (internalState.equals(ResultListenerState.FINISHED)) {
				return;
			}

			// also ignore everything if task is already finished
			// this happens if task is cancelled or timeouts while we are waiting
			// for messages
			if (pendingTask.getState().isFinished()) {
				logger.debug("Task " + pendingTask.getId() + " already finished, ignoring message.");
				internalState = ResultListenerState.FINISHED;
				return;
			}

			// error message can arrive at any state (real error, not failed
			// analysis)
			if (msg instanceof ResultMessage) {
				ResultMessage resultMessage = (ResultMessage) msg;
				if (JobState.ERROR.equals(resultMessage.getState())) {
					logger.debug("Task " + pendingTask.getId() + " got result message with ERROR.");
					taskFinished(State.ERROR, resultMessage.getStateDetail(), resultMessage);
					return;
				} else if (JobState.FAILED_USER_ERROR.equals(resultMessage.getState())) {
					taskFinished(State.FAILED_USER_ERROR, resultMessage.getStateDetail(), resultMessage);
					return;
				}
			}

			// ResultListener state machine
			switch (internalState) {
			case WAIT_FOR_STATUS:

				// status message
				if (msg instanceof ResultMessage) {
					ResultMessage resultMessage = (ResultMessage) msg;
					JobState jobState = resultMessage.getState();

					switch (jobState) {

					case NEW:
						// this isn't really used at the moment
						break;
					case RUNNING:
						updateTaskState(pendingTask, State.RUNNING, resultMessage.getStateDetail(), -1);
						break;
					case COMPLETED:
						updateTaskState(pendingTask, State.TRANSFERRING_OUTPUTS, null, -1);
						try {
							extractOutputs(resultMessage);
						} catch (Exception e) {
							logger.error("Getting outputs failed", e);
							e.printStackTrace();

							// usually taskFinished would pick the error message from
							// ResultMessage, but here we use the stack trace
							pendingTask.setErrorMessage(Exceptions.getStackTrace(e));
							taskFinished(State.ERROR, "Transferring outputs failed", null);
							break;
						}
						taskFinished(State.COMPLETED, null, resultMessage);
						break;
					case CANCELLED:
						taskFinished(State.CANCELLED, resultMessage.getStateDetail(), resultMessage);
						break;
					case FAILED:
						taskFinished(State.FAILED, resultMessage.getStateDetail(), resultMessage);
						break;
					case FAILED_USER_ERROR:
						taskFinished(State.FAILED_USER_ERROR, resultMessage.getStateDetail(), resultMessage);
						break;
					case TIMEOUT:
						// Task state TIMEOUT is reserved for communications timeout
						taskFinished(State.FAILED, resultMessage.getStateDetail(), resultMessage);
						break;
					default:
						break;
					}
				}
				break;
			default:
				break;
			}

			// Check if the task has been cancelled or client side timeout has occured while
			// processing the message.
			//
			// Task state can be finished without ResultListenerState being finished only
			// if someone external to ResultListenerState has changed the state of the task.
			// This happens when task is cancelled or timeout occurs. In such cases, possibly
			// created databeans are removed from the DataManager
			if (pendingTask.getState().isFinished() && internalState != ResultListenerState.FINISHED) {

				// update the state of the ResultListener
				internalState = ResultListenerState.FINISHED;

				// clean up possible output databeans
				// FIXME remove output databeans from manager!
			}
		}

		@Override
		public void cancel() {
			taskFinished(State.CANCELLED, null, null);
		}
		
		
		private void extractOutputs(ResultMessage resultMessage) throws JMSException, MicroarrayException, IOException {
			for (String key : resultMessage.getKeys()) {
				logger.debug("output " + key);
				String dataId = resultMessage.getId(key);
				String name = resultMessage.getName(key);
				if (name == null) {
					// tool didn't write a custom file name, just use the output name directly
					name = key;
				}
				DataBean bean = manager.createDataBean(name, dataId, true);
				pendingTask.addOutput(bean);
			}
		}

		/**
		 * Utility method for doing stuff that needs to be done when task finishes.
		 * 
		 * 
		 * @param state
		 * @param stateDetail
		 * @param resultMessage
		 *            may be null
		 */
		private void taskFinished(State state, String stateDetail, ResultMessage resultMessage) {

			// cleanup temp topic
			this.cleanUp();
			
			if (resultMessage != null) {
				// possible screen output
				if (resultMessage.getOutputText() != null) {
					pendingTask.setScreenOutput(resultMessage.getOutputText());
				}
				// possible error message
				if (resultMessage.getErrorMessage() != null) {
					pendingTask.setErrorMessage(resultMessage.getErrorMessage());
				}
				
				// source code
				pendingTask.getOperationRecord().setSourceCode(resultMessage.getSourceCode());
			}

			// end time(s)
			pendingTask.setEndTime(new Date());
			
			// clear job id, because it would cause job to continue when the
			// session is opened next time
			pendingTask.getOperationRecord().setJobId(null);

			// update state
			updateTaskState(pendingTask, state, stateDetail, -1);

			// update internal state
			this.internalState = ResultListenerState.FINISHED;

			// remove from running
			removeFromRunningTasks(pendingTask);
		}

	}

	public TaskExecutor(MessagingEndpoint endpoint, DataManager manager) throws Exception {
		this.manager = manager;
		this.requestTopic = endpoint.createTopic(Topics.Name.REQUEST_TOPIC, AccessMode.WRITE);
		this.jobExecutorStateChangeSupport = new SwingPropertyChangeSupport(this);
	}

	/**
	 * For unit testing, constructs partially incomplete object.
	 */
	protected TaskExecutor(DataManager manager) throws JMSException {
		this.manager = manager;
		this.jobExecutorStateChangeSupport = new SwingPropertyChangeSupport(this);
	}

	public Task createNewTask(OperationRecord operationRecord, boolean local) {
		// new job: create new id
		Task task = new Task(operationRecord, local);
		operationRecord.setJobId(task.getId());
		return task;
	}
	
	public Task createContinuedTask(OperationRecord operationRecord, boolean local) {
		// continued job: use existing id
		Task task = new Task(operationRecord, operationRecord.getJobId(), operationRecord.getStartTime(), operationRecord.getEndTime(),local);
		return task;
	}
	

	/**
	 * Non-blocking.
	 */
	public void startExecuting(Task task) throws TaskException {
		startExecuting(task, -1);
	}

	/**
	 * Starts executing task and create ResultMessageListener to receive results. ResultMessageListener will call TaskEventListener.
	 * TaskEventListener is guaranteed to be called inside Swing/AWT Event Dispatch Thread, so there can be a considerable delay between
	 * result message receiving and notification.
	 * 
	 * @param task
	 * @param taskEventListener
	 * @param timeout
	 * @throws TaskException
	 */
	public void startExecuting(final Task task, int timeout) throws TaskException {
		logger.debug("Starting task " + task.getName());

		if (task.isLocal()) {			
			
			Runnable taskRunnable = new LocalNGSPreprocess(task);
			ClientApplication app = Session.getSession().getApplication();
			app.runBlockingTask("running " + task.getFullName(), taskRunnable);
			return;
		}
		
		// log parameters
		List<String> parameters;
		try {
			parameters = task.getParameters();
			logger.debug("we have " + parameters.size() + " parameters");
			for (String parameter : parameters) {
				logger.debug("parameter: " + parameter);
			}
		} catch (MicroarrayException e1) {
			logger.error("Could not log parameters.");
		}

		// set task as running (task becomes visible in the task list)
		task.setStartTime(new Date());
		addToRunningTasks(task);

		
		
		// send job message (start task) in a background thread
		new Thread(new Runnable() {
			public void run() {
				try {
					
					JobMessage jobMessage = new JobMessage(task.getId(), task.getOperationID(), task.getParameters());

					// handle inputs
					logger.debug("adding inputs to job message");
					updateTaskState(task, State.TRANSFERRING_INPUTS, null, -1);
					int i = 0;
					
					for (InputRecord input : task.getInputRecords()) {
						String operationsInputName = input.getNameID().getID();
						final DataBean bean = input.getValue();
						final int fi = i;
						CopyProgressListener progressListener = new CopyProgressListener() {

							long length = Session.getSession().getApplication().getDataManager().getContentLength(bean);

							public void progress(long bytes) {
								float overall = ((float)fi) / ((float)task.getInputCount());
								float current = ((float)bytes) / ((float)length);
								float total = overall + (current / ((float)task.getInputCount()));
								updateTaskState(task, State.TRANSFERRING_INPUTS, null, Math.round(total * 100f));
							}
						};
						
						
						// transfer input contents to file broker if needed
						manager.uploadToCacheIfNeeded(bean, progressListener);
						
						// add the data id to the message
						jobMessage.addPayload(operationsInputName, bean.getId(), bean.getName());
						
						logger.debug("added input " + bean.getName() + " to job message.");
						i++;
					}				

					updateTaskState(task, State.WAITING, null, -1);
					TempTopicMessagingListener replyListener = new ResultMessageListener(task);
					logger.debug("sending job message, jobId: " + jobMessage.getJobId());

					requestTopic.sendReplyableMessage(jobMessage, replyListener);
					
				} catch (NotEnoughDiskSpaceException nedse) {
					logger.warn("received not enough disk space when uploading input", nedse);
					updateTaskState(task, State.FAILED_USER_ERROR, "Not enough disk space", -1);
					task.setErrorMessage("There was not enough disk space in Chipster server to run the task. Please try again later.");
					removeFromRunningTasks(task);									
					
				} catch (Exception e) {
					
					if (e instanceof IOException && "Stream Closed".equals(e.getMessage())) {
						String msg = "Uploading input data was interrupted. \n"
								+ "\n"
								+ "Source of the input data was lost before \n"
								+ "upload was completed. This may happen \n"
								+ "the current session file is replace by \n"
								+ "saving a new session with the same file name. \n"
								+ "\n"
								+ "Please run the tool again.\n";  
						updateTaskState(task, State.ERROR, msg, -1);
						removeFromRunningTasks(task);						
					} else {
						// could not send job message --> task fails
						logger.error("Could not send job message.", e);
						updateTaskState(task, State.ERROR, "Sending message failed: " + e.getMessage(), -1);
						removeFromRunningTasks(task);
					}
				}
			}
		}).start();
		logger.debug("task starter thread started");

		setupTimeoutTimer(task, timeout);
	}
	
	public void continueExecuting(final Task task) throws TaskException {
		continueExecuting(task, -1);
	}
	
	public void continueExecuting(final Task task, int timeout) throws TaskException {
		logger.debug("Continuing task " + task.getName());

		if (task.isLocal()) {						
			throw new IllegalArgumentException("local tasks cannot be continued");
		}

		// set task as running (task becomes visible in the task list)
		addToRunningTasks(task);

		// send job message (start task) in a background thread
		new Thread(new Runnable() {
			public void run() {
				try {
					CommandMessage commandMsg = new CommandMessage(CommandMessage.COMMAND_GET_JOB);
					commandMsg.addNamedParameter(ParameterMessage.PARAMETER_JOB_ID, task.getId());
										
					updateTaskState(task, State.WAITING, null, -1);
					TempTopicMessagingListener replyListener = new ResultMessageListener(task);
					logger.debug("sending get-job message, jobId: " + task.getId());

					requestTopic.sendReplyableMessage(commandMsg, replyListener);				
					
				} catch (Exception e) {
					// could not send job message --> task fails
					logger.error("Could not send get-job message.", e);
					updateTaskState(task, State.ERROR, "Sending message failed: " + e.getMessage(), -1);
					removeFromRunningTasks(task);
				}
			}
		}).start();
		logger.debug("task starter thread started");

		setupTimeoutTimer(task, timeout);
	}

	private void setupTimeoutTimer(Task task, int timeout) {
		// setup timeout checker if needed
		if (timeout != -1) {
			// we'll have to timeout this task
			Timer timer = new Timer(timeout, new TimeoutListener(task));
			timer.setRepeats(false);
			timer.start();
		}
	}

	/**
	 * Blocks until result is got. Can block infinitely, if no results are sent.
	 */
	public void execute(Task task) throws TaskException {

		startExecuting(task);

		// block until it is finished
		synchronized (runningTasks) {
			while (!task.getState().isFinished()) {
				try {
					runningTasks.wait(500);
				} catch (InterruptedException e) {
				}
			}
		}
	}

	public void kill(Task task) {
		logger.debug("TaskExecutor killing task " + task.getId());

		synchronized (task) {
			// task already finished?
			if (task.getState().isFinished()) {
				logger.debug("Task already finished, no need to cancel.");
				return;
			}
			updateTaskState(task, State.CANCELLED, null, -1);
		}

		// send the cancel message
		sendCancelMessage(task);

		removeFromRunningTasks(task);
	}
	
	public void killAll() {
		synchronized (runningTasks) {
			// copy of runningTasks, avoid concurrent modification by kill(Task task)
			LinkedList<Task> tasksToKill = new LinkedList<Task>(runningTasks);			
			killAll(tasksToKill);
		}
	}

	public void killAll(List<Task> tasks) {
		synchronized (runningTasks) {

			for (Task task : tasks) {
				kill(task);
			}

			SwingUtilities.invokeLater(new TaskExecutorChangeNotifier(this));
		}
	}
	
	public void killUploadingTasks() {
		synchronized (runningTasks) {
			killAll(getUploadingTasks());
		}		
	}

	public List<Task> getTasks(boolean onlyRunning, boolean showHidden) {
		synchronized (runningTasks) {
			// select if we return only running or all
			LinkedList<Task> taskList = onlyRunning ? runningTasks : tasks;

			// if we show also hidden, we can return
			if (showHidden) {
				return taskList;
			}

			// prune away hidden tasks
			LinkedList<Task> prunedTaskList = new LinkedList<Task>();
			for (Task task : taskList) {
				if (!task.isHidden()) {
					prunedTaskList.add(task);
				}
			}

			return prunedTaskList;
		}
	}

	private List<Task> getUploadingTasks() {
			
		synchronized (runningTasks) {
			Collection<Task> allTasks = getTasks(true, true);
			LinkedList<Task> uploadingTasks = new LinkedList<Task>();
			for (Task task : allTasks) {
				if (task.getState() == Task.State.NEW || 
						task.getState() == Task.State.TRANSFERRING_INPUTS) {
					uploadingTasks.add(task);
				}
			}
			return uploadingTasks;
		}
	}
	
	public int getRunningTaskCount() {
		synchronized (runningTasks) {
			Collection<Task> taskList = getTasks(true, false);
			return taskList.size();
		}
	}
	

	public int getUploadingTaskCount() {
		return getUploadingTasks().size();
	}

	/**
	 * Adds a listener for general task execution state (how many tasks are running etc).
	 */
	public void addChangeListener(PropertyChangeListener listener) {
		jobExecutorStateChangeSupport.addPropertyChangeListener(listener);
	}

	public boolean isEventsEnabled() {
		return eventsEnabled;
	}

	public void setEventsEnabled(boolean eventsEnabled) {
		this.eventsEnabled = eventsEnabled;
	}

	/**
	 * If task is already finished, state remains unmodified.
	 * 
	 * 
	 * @param task
	 * @param state
	 * @param stateDetail
	 * @param completionPercentage 
	 */
	private void updateTaskState(Task task, State state, String stateDetail, int completionPercentage) {
		
		State oldState;				

		// setting the state will notify Task listeners
		synchronized (task) {

			// already finished, do not change the state
			if (task.getState().isFinished()) {
				return;
			}

			// not finished yet, change state
			else {
				
				oldState = task.getState();
				task.setState(state);
				if (stateDetail != null) {
					task.setStateDetail(stateDetail);
				}
				
				task.setCompletionPercentage(completionPercentage);
				
				// notify TaskExecutor listeners
				SwingUtilities.invokeLater(new TaskExecutorChangeNotifier(this));
			}
		}
				
		/*
		 * do this outside synchronized of the block, because sometimes this
		 * triggers GUI updates that try to access TaskExecutor from EDT
		 */
		if (oldState != null) {
			task.notifyTaskStateChangeListener(oldState, state);
		}
	}

	private void dispatch(PropertyChangeEvent event) {
		if (eventsEnabled) {
			jobExecutorStateChangeSupport.firePropertyChange(event);
		}
	}

	protected void addToRunningTasks(Task task) {
		synchronized (runningTasks) {
			tasks.add(task);
			runningTasks.add(task);
			runningTasks.notifyAll();
		}
		SwingUtilities.invokeLater(new TaskExecutorChangeNotifier(this));
	}

	protected void removeFromRunningTasks(Task task) {
		synchronized (runningTasks) {
			runningTasks.remove(task);
			runningTasks.notifyAll();
		}
		SwingUtilities.invokeLater(new TaskExecutorChangeNotifier(this));
	}

	private void sendCancelMessage(final Task task) {
		logger.debug("Sending cancel message for " + task.getId());

		// send message in a background thread
		new Thread(new Runnable() {
			public void run() {
				try {
					// create message
					CommandMessage commandMessage = new CommandMessage(CommandMessage.COMMAND_CANCEL);
					commandMessage.addNamedParameter(ParameterMessage.PARAMETER_JOB_ID, task.getId());

					// send message
					logger.debug("Sending cancel message.");
					requestTopic.sendMessage(commandMessage);

				} catch (Exception e) {
					logger.error("Could not send cancel message for " + task.getId(), e);
				}
			}
		}).start();
		logger.debug("Message cancel thread started.");
	}

	public void clear() {
		synchronized (runningTasks) {
			runningTasks.clear();
			tasks.clear();
		}
		SwingUtilities.invokeLater(new TaskExecutorChangeNotifier(this));
	}
}