/**
 * This Source Code Form is subject to the terms of the Mozilla Public License,
 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
 * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under
 * the terms of the Healthcare Disclaimer located at http://openmrs.org/license.
 *
 * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS
 * graphic logo is a trademark of OpenMRS Inc.
 */
package org.openmrs.scheduler.timer;

import java.util.ArrayList;
import java.util.Collection;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.SortedMap;
import java.util.Timer;
import java.util.TreeMap;
import java.util.WeakHashMap;

import org.openmrs.api.APIException;
import org.openmrs.api.impl.BaseOpenmrsService;
import org.openmrs.scheduler.SchedulerConstants;
import org.openmrs.scheduler.SchedulerException;
import org.openmrs.scheduler.SchedulerService;
import org.openmrs.scheduler.SchedulerUtil;
import org.openmrs.scheduler.Task;
import org.openmrs.scheduler.TaskDefinition;
import org.openmrs.scheduler.TaskFactory;
import org.openmrs.scheduler.db.SchedulerDAO;
import org.openmrs.util.OpenmrsMemento;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.orm.ObjectRetrievalFailureException;
import org.springframework.transaction.annotation.Transactional;

/**
 * Simple scheduler service that uses JDK timer to trigger and execute scheduled tasks.
 */
@Transactional
public class TimerSchedulerServiceImpl extends BaseOpenmrsService implements SchedulerService {
	
	/**
	 * Logger
	 */
	private static final Logger log = LoggerFactory.getLogger(TimerSchedulerServiceImpl.class);
	
	/**
	 * Registered task list
	 */
	private Set<TaskDefinition> registeredTasks = new HashSet<>();
	
	/**
	 * Scheduled Task Map
	 */
	private static Map<Integer, TimerSchedulerTask> scheduledTasks = new WeakHashMap<>();
	
	/**
	 * A single timer used to keep track of all scheduled tasks. The Timer's associated thread
	 * should run as a daemon. A deamon thread is called for if the timer will be used to schedule
	 * repeating "maintenance activities", which must be performed as long as the application is
	 * running, but should not prolong the lifetime of the application.
	 *
	 * @see java.util.Timer#Timer(boolean)
	 */
	private Map<TaskDefinition, Timer> taskDefinitionTimerMap = new HashMap<>();
	
	/**
	 * Global data access object context
	 */
	private SchedulerDAO schedulerDAO;
	
	/**
	 * Gets the scheduler data access object.
	 */
	public SchedulerDAO getSchedulerDAO() {
		return this.schedulerDAO;
	}
	
	/**
	 * Sets the scheduler data access object.
	 */
	public void setSchedulerDAO(SchedulerDAO dao) {
		this.schedulerDAO = dao;
	}
	
	/**
	 * Start up hook for the scheduler and all of its scheduled tasks.
	 */
	@Override
	public void onStartup() {
		log.debug("Starting scheduler service ...");
		
		// Get all of the tasks in the database
		Collection<TaskDefinition> taskDefinitions = getSchedulerDAO().getTasks();
		
		// Iterate through the tasks and start them if their startOnStartup flag is true
		if (taskDefinitions != null) {
			for (TaskDefinition taskDefinition : taskDefinitions) {
				try {
					// If the task is configured to start on startup, we schedule it to run
					// Otherwise it needs to be started manually.
					if (taskDefinition.getStartOnStartup()) {
						scheduleTask(taskDefinition);
					}
					
				}
				catch (Exception e) {
					log.error("Failed to schedule task for class " + taskDefinition.getTaskClass(), e);
				}
			}
		}
	}
	
	public static void setScheduledTasks(Map<Integer, TimerSchedulerTask> scheduledTasks) {
		if (scheduledTasks != null) {
			TimerSchedulerServiceImpl.scheduledTasks = scheduledTasks;
		} else {
			TimerSchedulerServiceImpl.scheduledTasks = new WeakHashMap<>();
		}
	}
	
	/**
	 * Shutdown hook for the scheduler and all of its scheduled tasks.
	 */
	@Override
	public void onShutdown() {
		log.debug("Gracefully shutting down scheduler service ...");
		// gracefully shutdown all tasks and remove all references to the timers, scheduler
		try {
			shutdownAllTasks();
			cancelAllTimers(); // Just a precaution - this shouldn't be necessary if shutdownAllTasks() does its job
		}
		catch (APIException e) {
			log.error("Failed to stop all tasks due to API exception", e);
		}
		finally {
			setScheduledTasks(null);
		}
		
	}
	
	/**
	 * Convenience method to stop all tasks in the {@link #taskDefinitionTimerMap}
	 */
	private void cancelAllTimers() {
		for (Timer timer : taskDefinitionTimerMap.values()) {
			timer.cancel();
		}
	}
	
	/**
	 * Shutdown all running tasks.
	 */
	public void shutdownAllTasks() {
		
		// iterate over this (copied) list of tasks and stop them all
		for (TaskDefinition task : getScheduledTasks()) {
			try {
				
				shutdownTask(task);
				
			}
			catch (SchedulerException e) {
				log.error("Failed to stop task " + task.getTaskClass() + " due to Scheduler exception", e);
			}
			catch (APIException e) {
				log.error("Failed to stop task " + task.getTaskClass() + " due to API exception", e);
			}
		}
	}
	
	/**
	 * Get the {@link Timer} that is assigned to the given {@link TaskDefinition} object. If a Timer
	 * doesn't exist yet, one is created, added to {@link #taskDefinitionTimerMap} and then returned
	 *
	 * @param taskDefinition the {@link TaskDefinition} to look for
	 * @return the {@link Timer} associated with the given {@link TaskDefinition}
	 */
	private Timer getTimer(TaskDefinition taskDefinition) {
		Timer timer;
		if (taskDefinitionTimerMap.containsKey(taskDefinition)) {
			timer = taskDefinitionTimerMap.get(taskDefinition);
		} else {
			timer = new Timer(true);
			taskDefinitionTimerMap.put(taskDefinition, timer);
		}
		
		return timer;
	}
	
	/**
	 * Schedule the given task according to the given schedule.
	 *
	 * @param taskDefinition the task to be scheduled
	 * <strong>Should</strong> should handle zero repeat interval
	 */
	@Override
	public Task scheduleTask(TaskDefinition taskDefinition) throws SchedulerException {
		Task clientTask = null;
		if (taskDefinition != null) {
			
			// Cancel any existing timer tasks for the same task definition
			// TODO Make sure this is the desired behavior 
			// TODO Do we ever want the same task definition to run more than once?
			TimerSchedulerTask schedulerTask = scheduledTasks.get(taskDefinition.getId());
			if (schedulerTask != null) {
				log.info("Shutting down the existing instance of this task to avoid conflicts!!");
				schedulerTask.shutdown();
			}
			
			try {
				
				// Create new task from task definition 
				clientTask = TaskFactory.getInstance().createInstance(taskDefinition);
				
				// if we were unable to get a class, just quit
				if (clientTask != null) {
					
					schedulerTask = new TimerSchedulerTask(clientTask);
					taskDefinition.setTaskInstance(clientTask);
					
					// Once this method is called, the timer is set to start at the given start time.
					// NOTE:  We need to adjust the repeat interval as the JDK Timer expects time in milliseconds and 
					// we record by seconds.  
					
					long repeatInterval = 0;
					if (taskDefinition.getRepeatInterval() != null) {
						repeatInterval = taskDefinition.getRepeatInterval() * SchedulerConstants.SCHEDULER_MILLIS_PER_SECOND;
					}
					
					if (taskDefinition.getStartTime() != null) {
						// Need to calculate the "next execution time" because the scheduled time is most likely in the past
						// and the JDK timer will run the task X number of times from the start time until now to catch up.
						Date nextTime = SchedulerUtil.getNextExecution(taskDefinition);
						
						// Start task at fixed rate at given future date and repeat as directed 							
						log.info("Starting task ... the task will execute for the first time at " + nextTime);
						
						if (repeatInterval > 0) {
							// Schedule the task to run at a fixed rate
							getTimer(taskDefinition).scheduleAtFixedRate(schedulerTask, nextTime, repeatInterval);
						} else {
							// Schedule the task to be non-repeating
							getTimer(taskDefinition).schedule(schedulerTask, nextTime);
						}
						
					} else if (repeatInterval > 0) {
						// Start task on repeating schedule, delay for SCHEDULER_DEFAULT_DELAY seconds	
						log.info("Delaying start time by " + SchedulerConstants.SCHEDULER_DEFAULT_DELAY + " seconds");
						getTimer(taskDefinition).scheduleAtFixedRate(schedulerTask,
						    SchedulerConstants.SCHEDULER_DEFAULT_DELAY, repeatInterval);
					} else {
						// schedule for single execution, starting now
						log.info("Starting one-shot task");
						getTimer(taskDefinition).schedule(schedulerTask, new Date());
					}
					
					// Update task that has been started
					log.debug("Registering timer for task " + taskDefinition.getId());
					
					//  Add the new timer to the scheduler running task list  
					scheduledTasks.put(taskDefinition.getId(), schedulerTask);
					
					// Update the timer status in the database
					taskDefinition.setStarted(true);
					saveTaskDefinition(taskDefinition);
				}
			}
			catch (Exception e) {
				log.error("Failed to schedule task " + taskDefinition.getName(), e);
				throw new SchedulerException("Failed to schedule task", e);
			}
		}
		return clientTask;
	}
	
	/**
	 * Stops a running task.
	 *
	 * @param taskDefinition the task to be stopped
	 * @see org.openmrs.scheduler.SchedulerService#shutdownTask(TaskDefinition)
	 */
	@Override
	public void shutdownTask(TaskDefinition taskDefinition) throws SchedulerException {
		if (taskDefinition != null) {
			
			// Remove the task from the scheduled tasks and shutdown the timer
			TimerSchedulerTask schedulerTask = scheduledTasks.remove(taskDefinition.getId());
			if (schedulerTask != null) {
				schedulerTask.shutdown(); // Stops the timer and tells the timer task to release its resources 
			}
			
			// Update task that has been started
			taskDefinition.setStarted(false);
			saveTaskDefinition(taskDefinition);
		}
	}
	
	/**
	 * Loop over all currently started tasks and cycle them. This should be done after the
	 * classloader has been changed (e.g. during module start/stop)
	 */
	@Override
	public void rescheduleAllTasks() throws SchedulerException {
		for (TaskDefinition task : getScheduledTasks()) {
			try {
				rescheduleTask(task);
			}
			catch (SchedulerException e) {
				log.error("Failed to restart task: " + task.getName(), e);
			}
		}
	}
	
	/**
	 * @see org.openmrs.scheduler.SchedulerService#rescheduleTask(org.openmrs.scheduler.TaskDefinition)
	 */
	@Override
	public Task rescheduleTask(TaskDefinition taskDefinition) throws SchedulerException {
		shutdownTask(taskDefinition);
		return scheduleTask(taskDefinition);
	}
	
	/**
	 * Register a new task by adding it to our task map with an empty schedule map.
	 *
	 * @param definition task to register
	 */
	public void registerTask(TaskDefinition definition) {
		registeredTasks.add(definition);
	}
	
	/**
	 * Get all scheduled tasks.
	 *
	 * @return all scheduled tasks
	 */
	@Override
	public Collection<TaskDefinition> getScheduledTasks() {
		// The real list of scheduled tasks is kept up-to-date in the scheduledTasks map
		// TODO change the index for the scheduledTasks map to be the TaskDefinition rather than the ID
		List<TaskDefinition> list = new ArrayList<>();
		if (scheduledTasks != null) {
			Set<Integer> taskIds = scheduledTasks.keySet();
			for (Integer id : taskIds) {
				TaskDefinition task = getTask(id);
				log.debug("Adding scheduled task " + id + " to list (" + task.getRepeatInterval() + ")");
				list.add(task);
			}
		}
		return list;
		
	}
	
	/**
	 * Get all registered tasks.
	 *
	 * @return all registerd tasks
	 */
	@Override
	@Transactional(readOnly = true)
	public Collection<TaskDefinition> getRegisteredTasks() {
		return getSchedulerDAO().getTasks();
	}
	
	/**
	 * Get the task with the given identifier.
	 *
	 * @param id the identifier of the task
	 */
	@Override
	@Transactional(readOnly = true)
	public TaskDefinition getTask(Integer id) {
		log.debug("get task {}", id);
		return getSchedulerDAO().getTask(id);
	}
	
	/**
	 * Get the task with the given name.
	 *
	 * @param name name of the task
	 */
	@Override
	@Transactional(readOnly = true)
	public TaskDefinition getTaskByName(String name) {
		log.debug("get task {}", name);
		TaskDefinition foundTask = null;
		try {
			foundTask = getSchedulerDAO().getTaskByName(name);
		}
		catch (ObjectRetrievalFailureException orfe) {
			log.warn("getTaskByName(" + name + ") failed, because: " + orfe);
		}
		return foundTask;
	}
	
	/**
	 * Save a task in the database.
	 *
	 * @param task the <code>TaskDefinition</code> to save
	 */
	@Override
	public void saveTaskDefinition(TaskDefinition task) {
		if (task.getId() != null) {
			getSchedulerDAO().updateTask(task);
		} else {
			getSchedulerDAO().createTask(task);
		}
	}
	
	/**
	 * Delete the task with the given identifier.
	 *
	 * @param id the identifier of the task
	 */
	@Override
	public void deleteTask(Integer id) {
		
		TaskDefinition task = getTask(id);
		if (task.getStarted()) {
			throw new APIException("Scheduler.timer.task.delete", (Object[]) null);
		}
		
		// delete the task
		getSchedulerDAO().deleteTask(id);
	}
	
	/**
	 * Get system variables.
	 */
	@Override
	public SortedMap<String, String> getSystemVariables() {
		SortedMap<String, String> systemVariables = new TreeMap<>();
		// scheduler username and password can be found in the global properties
		// TODO Look into java.util.concurrent.TimeUnit class.  
		// TODO Remove this from global properties.  This is a constant value that should never change.  
		systemVariables.put("SCHEDULER_MILLIS_PER_SECOND", String.valueOf(SchedulerConstants.SCHEDULER_MILLIS_PER_SECOND));
		return systemVariables;
	}
	
	/**
	 * Saves and stops all active tasks
	 *
	 * @return OpenmrsMemento
	 */
	@Override
	public OpenmrsMemento saveToMemento() {
		
		Set<Integer> tasks = new HashSet<>();
		
		for (TaskDefinition task : getScheduledTasks()) {
			tasks.add(task.getId());
			try {
				shutdownTask(task);
			}
			catch (SchedulerException e) {
				// just swallow exceptions
				log.debug("Failed to stop task while saving memento " + task.getName(), e);
			}
		}
		
		TimerSchedulerMemento memento = new TimerSchedulerMemento(tasks);
		memento.saveErrorTasks();
		
		return memento;
	}
	
	/**
	 *
	 */
	@Override
	@SuppressWarnings("unchecked")
	public void restoreFromMemento(OpenmrsMemento memento) {
		
		if (memento != null && memento instanceof TimerSchedulerMemento) {
			TimerSchedulerMemento timerMemento = (TimerSchedulerMemento) memento;
			
			Set<Integer> taskIds = (HashSet<Integer>) timerMemento.getState();
			
			// try to start all of the tasks that were stopped right before this restore
			for (Integer taskId : taskIds) {
				TaskDefinition task = getTask(taskId);
				try {
					scheduleTask(task);
				}
				catch (Exception e) {
					// essentially swallow exceptions
					log.debug("EXPECTED ERROR IF STOPPING THIS TASK'S MODULE: Unable to start task " + taskId, e);
					
					// save this errored task and try again next time we restore
					timerMemento.addErrorTask(taskId);
				}
			}
		}
	}
	
	/**
	 * @see org.openmrs.scheduler.SchedulerService#getStatus(java.lang.Integer) TODO
	 *      internationalization of string status messages
	 */
	@Override
	public String getStatus(Integer id) {
		
		// Get the scheduled timer task
		TimerSchedulerTask scheduledTask = scheduledTasks.get(id);
		
		if (scheduledTask != null) {
			if (scheduledTask.scheduledExecutionTime() > 0) {
				return "Scheduled to execute at " + new Date(scheduledTask.scheduledExecutionTime());
			} else {
				return "Currently executing";
			}
		}
		return "Not Running";
	}
	
	@Override
	public void scheduleIfNotRunning(TaskDefinition taskDef) {
		Task task = taskDef.getTaskInstance();
		if (task == null) {
			try {
				scheduleTask(taskDef);
			}
			catch (SchedulerException e) {
				log.error("Failed to schedule task, because:", e);
			}
		} else if (!task.isExecuting()) {
			try {
				rescheduleTask(taskDef);
			}
			catch (SchedulerException e) {
				log.error("Failed to re-schedule task, because:", e);
			}
		}
	}

	/**
	 * @see org.openmrs.scheduler.SchedulerService#getTaskByUuid(java.lang.String)
	 *
	 */
	@Override
	@Transactional(readOnly = true)
	public TaskDefinition getTaskByUuid(String uuid) {
		return getSchedulerDAO().getTaskByUuid(uuid);
	}
	
}
