<?php
/**
 * @package     Joomla.Administrator
 * @subpackage  com_scheduler
 *
 * @copyright   (C) 2021 Open Source Matters, Inc. <https://www.joomla.org>
 * @license     GNU General Public License version 2 or later; see LICENSE.txt
 */

namespace Joomla\Component\Scheduler\Administrator\Scheduler;

// Restrict direct access
\defined('_JEXEC') or die;

use Joomla\CMS\Application\CMSApplication;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Log\Log;
use Joomla\Component\Scheduler\Administrator\Extension\SchedulerComponent;
use Joomla\Component\Scheduler\Administrator\Model\TaskModel;
use Joomla\Component\Scheduler\Administrator\Model\TasksModel;
use Joomla\Component\Scheduler\Administrator\Task\Status;
use Joomla\Component\Scheduler\Administrator\Task\Task;
use Symfony\Component\OptionsResolver\Exception\AccessException;
use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException;
use Symfony\Component\OptionsResolver\Exception\UndefinedOptionsException;
use Symfony\Component\OptionsResolver\OptionsResolver;

/**
 * The Scheduler class provides the core functionality of ComScheduler.
 * Currently, this includes fetching scheduled tasks from the database
 * and execution of any or the next due task.
 * It is planned that this class is extended with C[R]UD methods for
 * scheduled tasks.
 *
 * @since 4.1.0
 * @todo  A global instance?
 */
class Scheduler
{
	private const LOG_TEXT = [
		Status::OK          => 'COM_SCHEDULER_SCHEDULER_TASK_COMPLETE',
		Status::WILL_RESUME => 'COM_SCHEDULER_SCHEDULER_TASK_WILL_RESUME',
		Status::NO_LOCK     => 'COM_SCHEDULER_SCHEDULER_TASK_LOCKED',
		Status::NO_RUN      => 'COM_SCHEDULER_SCHEDULER_TASK_UNLOCKED',
		Status::NO_ROUTINE  => 'COM_SCHEDULER_SCHEDULER_TASK_ROUTINE_NA',
	];

	/**
	 * Filters for the task queue. Can be used with fetchTaskRecords().
	 *
	 * @since 4.1.0
	 * @todo  remove?
	 */
	public const TASK_QUEUE_FILTERS = [
		'due'    => 1,
		'locked' => -1,
	];

	/**
	 * List config for the task queue. Can be used with fetchTaskRecords().
	 *
	 * @since 4.1.0
	 * @todo  remove?
	 */
	public const TASK_QUEUE_LIST_CONFIG = [
		'multi_ordering' => ['a.priority DESC ', 'a.next_execution ASC'],
	];

	/**
	 * Run a scheduled task.
	 * Runs a single due task from the task queue by default if $id and $title are not passed.
	 *
	 * @param   array  $options  Array with options to configure the method's behavior. Supports:
	 *                           1. `id`: (Optional) ID of the task to run.
	 *                           2. `allowDisabled`: Allow running disabled tasks.
	 *                           3. `allowConcurrent`: Allow concurrent execution, i.e., running the task when another
	 *                           task may be running.
	 *
	 * @return ?Task  The task executed or null if not exists
	 *
	 * @since 4.1.0
	 * @throws \RuntimeException
	 */
	public function runTask(array $options): ?Task
	{
		$resolver = new OptionsResolver;

		try
		{
			$this->configureTaskRunnerOptions($resolver);
		}
		catch (\Exception $e)
		{
		}

		try
		{
			$options = $resolver->resolve($options);
		}
		catch (\Exception $e)
		{
			if ($e instanceof UndefinedOptionsException || $e instanceof InvalidOptionsException)
			{
				throw $e;
			}
		}

		/** @var CMSApplication $app */
		$app = Factory::getApplication();

		// ? Sure about inferring scheduling bypass?
		$task = $this->getTask(
			[
				'id'                  => (int) $options['id'],
				'allowDisabled'       => $options['allowDisabled'],
				'bypassScheduling'    => (int) $options['id'] !== 0,
				'allowConcurrent'     => $options['allowConcurrent'],
				'includeCliExclusive' => ($app->isClient('cli')),
			]
		);

		// ? Should this be logged? (probably, if an ID is passed?)
		if (empty($task))
		{
			return null;
		}

		$app->getLanguage()->load('com_scheduler', JPATH_ADMINISTRATOR);

		$options['text_entry_format'] = '{DATE}	{TIME}	{PRIORITY}	{MESSAGE}';
		$options['text_file']         = 'joomla_scheduler.php';
		Log::addLogger($options, Log::ALL, $task->logCategory);

		$taskId    = $task->get('id');
		$taskTitle = $task->get('title');

		$task->log(Text::sprintf('COM_SCHEDULER_SCHEDULER_TASK_START', $taskId, $taskTitle), 'info');

		// Let's try to avoid time-outs
		if (\function_exists('set_time_limit'))
		{
			set_time_limit(0);
		}

		try
		{
			$task->run();
		}
		catch (\Exception $e)
		{
			// We suppress the exception here, it's still accessible with `$task->getContent()['exception']`.
		}

		$executionSnapshot = $task->getContent();
		$exitCode          = $executionSnapshot['status'] ?? Status::NO_EXIT;
		$netDuration       = $executionSnapshot['netDuration'] ?? 0;
		$duration          = $executionSnapshot['duration'] ?? 0;

		if (\array_key_exists($exitCode, self::LOG_TEXT))
		{
			$level = in_array($exitCode, [Status::OK, Status::WILL_RESUME]) ? 'info' : 'warning';
			$task->log(Text::sprintf(self::LOG_TEXT[$exitCode], $taskId, $duration, $netDuration), $level);

			return $task;
		}

		$task->log(
			Text::sprintf('COM_SCHEDULER_SCHEDULER_TASK_UNKNOWN_EXIT', $taskId, $duration, $netDuration, $exitCode),
			'warning'
		);

		return $task;
	}

	/**
	 * Set up an {@see OptionsResolver} to resolve options compatible with {@see runTask}.
	 *
	 * @param   OptionsResolver  $resolver  The {@see OptionsResolver} instance to set up.
	 *
	 * @return void
	 *
	 * @since 4.1.0
	 * @throws AccessException
	 */
	protected function configureTaskRunnerOptions(OptionsResolver $resolver): void
	{
		$resolver->setDefaults(
			[
				'id' => 0,
				'allowDisabled' => false,
				'allowConcurrent' => false,
			]
		)
			->setAllowedTypes('id', 'numeric')
			->setAllowedTypes('allowDisabled', 'bool')
			->setAllowedTypes('allowConcurrent', 'bool');
	}

	/**
	 * Get the next task which is due to run, limit to a specific task when ID is given
	 *
	 * @param   array  $options  Options for the getter, see {@see TaskModel::getTask()}.
	 *                           ! should probably also support a non-locking getter.
	 *
	 * @return  Task $task The task to execute
	 *
	 * @since 4.1.0
	 * @throws \RuntimeException
	 */
	public function getTask(array $options = []): ?Task
	{
		$resolver = new OptionsResolver;

		try
		{
			TaskModel::configureTaskGetterOptions($resolver);
		}
		catch (\Exception $e)
		{
		}

		try
		{
			$options = $resolver->resolve($options);
		}
		catch (\Exception $e)
		{
			if ($e instanceof UndefinedOptionsException || $e instanceof InvalidOptionsException)
			{
				throw $e;
			}
		}

		try
		{
			/** @var SchedulerComponent $component */
			$component = Factory::getApplication()->bootComponent('com_scheduler');

			/** @var TaskModel $model */
			$model = $component->getMVCFactory()->createModel('Task', 'Administrator', ['ignore_request' => true]);
		}
		catch (\Exception $e)
		{
		}

		if (!isset($model))
		{
			throw new \RuntimeException('JLIB_APPLICATION_ERROR_MODEL_CREATE');
		}

		$task = $model->getTask($options);

		if (empty($task))
		{
			return null;
		}

		return new Task($task);
	}

	/**
	 * Fetches a single scheduled task in a Task instance.
	 * If no id or title is specified, a due task is returned.
	 *
	 * @param   int   $id             The task ID.
	 * @param   bool  $allowDisabled  Allow disabled/trashed tasks?
	 *
	 * @return ?object  A matching task record, if it exists
	 *
	 * @since 4.1.0
	 * @throws \RuntimeException
	 */
	public function fetchTaskRecord(int $id = 0, bool $allowDisabled = false): ?object
	{
		$filters    = [];
		$listConfig = ['limit' => 1];

		if ($id > 0)
		{
			$filters['id'] = $id;
		}
		else
		{
			// Filters and list config for scheduled task queue
			$filters['due']               = 1;
			$filters['locked']            = -1;
			$listConfig['multi_ordering'] = [
				'a.priority DESC',
				'a.next_execution ASC',
			];
		}

		if ($allowDisabled)
		{
			$filters['state'] = '';
		}

		return $this->fetchTaskRecords($filters, $listConfig)[0] ?? null;
	}

	/**
	 * @param   array  $filters     The filters to set to the model
	 * @param   array  $listConfig  The list config (ordering, etc.) to set to the model
	 *
	 * @return array
	 *
	 * @since 4.1.0
	 * @throws \RunTimeException
	 */
	public function fetchTaskRecords(array $filters, array $listConfig): array
	{
		$model = null;

		try
		{
			/** @var SchedulerComponent $component */
			$component = Factory::getApplication()->bootComponent('com_scheduler');

			/** @var TasksModel $model */
			$model = $component->getMVCFactory()
				->createModel('Tasks', 'Administrator', ['ignore_request' => true]);
		}
		catch (\Exception $e)
		{
		}

		if (!$model)
		{
			throw new \RuntimeException('JLIB_APPLICATION_ERROR_MODEL_CREATE');
		}

		$model->setState('list.select', 'a.*');

		// Default to only enabled tasks
		if (!isset($filters['state']))
		{
			$model->setState('filter.state', 1);
		}

		// Default to including orphaned tasks
		$model->setState('filter.orphaned', 0);

		// Default to ordering by ID
		$model->setState('list.ordering', 'a.id');
		$model->setState('list.direction', 'ASC');

		// List options
		foreach ($listConfig as $key => $value)
		{
			$model->setState('list.' . $key, $value);
		}

		// Filter options
		foreach ($filters as $type => $filter)
		{
			$model->setState('filter.' . $type, $filter);
		}

		return $model->getItems() ?: [];
	}
}
