<?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\Task;

use Joomla\CMS\Application\CMSApplication;
use Joomla\CMS\Component\ComponentHelper;
use Joomla\CMS\Event\AbstractEvent;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Log\Log;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\Component\Scheduler\Administrator\Event\ExecuteTaskEvent;
use Joomla\Component\Scheduler\Administrator\Helper\ExecRuleHelper;
use Joomla\Component\Scheduler\Administrator\Helper\SchedulerHelper;
use Joomla\Component\Scheduler\Administrator\Scheduler\Scheduler;
use Joomla\Component\Scheduler\Administrator\Table\TaskTable;
use Joomla\Database\DatabaseInterface;
use Joomla\Database\ParameterType;
use Joomla\Registry\Registry;
use Joomla\Utilities\ArrayHelper;
use Psr\Log\InvalidArgumentException;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;

// phpcs:disable PSR1.Files.SideEffects
\defined('_JEXEC') or die;
// phpcs:enable PSR1.Files.SideEffects

/**
 * The Task class defines methods for the execution, logging and
 * related properties of Tasks as supported by `com_scheduler`,
 * a Task Scheduling component.
 *
 * @since 4.1.0
 */
class Task implements LoggerAwareInterface
{
    use LoggerAwareTrait;

    /**
     * Enumerated state for enabled tasks.
     *
     * @since 4.1.0
     */
    public const STATE_ENABLED = 1;

    /**
     * Enumerated state for disabled tasks.
     *
     * @since 4.1.0
     */
    public const STATE_DISABLED = 0;

    /**
     * Enumerated state for trashed tasks.
     *
     * @since 4.1.0
     */
    public const STATE_TRASHED = -2;

    /**
     * Map state enumerations to logical language adjectives.
     *
     * @since 4.1.0
     */
    public const STATE_MAP = [
        self::STATE_TRASHED  => 'trashed',
        self::STATE_DISABLED => 'disabled',
        self::STATE_ENABLED  => 'enabled',
    ];

    /**
     * The task snapshot
     *
     * @var   array
     * @since 4.1.0
     */
    protected $snapshot = [];

    /**
     * @var  Registry
     * @since 4.1.0
     */
    protected $taskRegistry;

    /**
     * @var  string
     * @since  4.1.0
     */
    public $logCategory;

    /**
     * @var  CMSApplication
     * @since  4.1.0
     */
    protected $app;

    /**
     * @var  DatabaseInterface
     * @since  4.1.0
     */
    protected $db;

    /**
     * Maps task exit codes to events which should be dispatched when the task finishes.
     * 'NA' maps to the event for general task failures.
     *
     * @var  string[]
     * @since  4.1.0
     */
    protected const EVENTS_MAP = [
        Status::OK          => 'onTaskExecuteSuccess',
        Status::NO_ROUTINE  => 'onTaskRoutineNotFound',
        Status::WILL_RESUME => 'onTaskRoutineWillResume',
        'NA'                => 'onTaskExecuteFailure',
    ];

    /**
     * Constructor for {@see Task}.
     *
     * @param   object  $record  A task from {@see TaskTable}.
     *
     * @since 4.1.0
     * @throws \Exception
     */
    public function __construct(object $record)
    {
        // Workaround because Registry dumps private properties otherwise.
        $taskOption     = $record->taskOption;
        $record->params = json_decode($record->params, true);

        $this->taskRegistry = new Registry($record);

        $this->set('taskOption', $taskOption);
        $this->app = Factory::getApplication();
        $this->db  = Factory::getContainer()->get(DatabaseInterface::class);
        $this->setLogger(Log::createDelegatedLogger());
        $this->logCategory = 'task' . $this->get('id');

        if ($this->get('params.individual_log')) {
            $logFile = $this->get('params.log_file') ?? 'task_' . $this->get('id') . '.log.php';

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

    /**
     * Get the task as a data object that can be stored back in the database.
     * ! This method should be removed or changed as part of a better API implementation for the driver.
     *
     * @return object
     *
     * @since 4.1.0
     */
    public function getRecord(): object
    {
        // ! Probably, an array instead
        $recObject = $this->taskRegistry->toObject();

        $recObject->cron_rules = (array) $recObject->cron_rules;

        return $recObject;
    }

    /**
     * Execute the task.
     *
     * @return boolean  True if success
     *
     * @since 4.1.0
     * @throws \Exception
     */
    public function run(): bool
    {
        /**
         * We try to acquire the lock here, only if we don't already have one.
         * We do this, so we can support two ways of running tasks:
         *   1. Directly through {@see Scheduler}, which optimises acquiring a lock while fetching from the task queue.
         *   2. Running a task without a pre-acquired lock.
         * ! This needs some more thought, for whether it should be allowed or if the single-query optimisation
         *   should be used everywhere, although it doesn't make sense in the context of fetching
         *   a task when it doesn't need to be run. This might be solved if we force a re-fetch
         *   with the lock or do it here ourselves (using acquireLock as a proxy to the model's
         *   getter).
         */
        if ($this->get('locked') === null) {
            $this->acquireLock();
        }

        // Exit early if task routine is not available
        if (!SchedulerHelper::getTaskOptions()->findOption($this->get('type'))) {
            $this->snapshot['status'] = Status::NO_ROUTINE;
            $this->skipExecution();
            $this->dispatchExitEvent();

            return $this->isSuccess();
        }

        $this->snapshot['status']      = Status::RUNNING;
        $this->snapshot['taskStart']   = $this->snapshot['taskStart'] ?? microtime(true);
        $this->snapshot['netDuration'] = 0;

        /** @var ExecuteTaskEvent $event */
        $event = AbstractEvent::create(
            'onExecuteTask',
            [
                'eventClass'      => ExecuteTaskEvent::class,
                'subject'         => $this,
                'routineId'       => $this->get('type'),
                'langConstPrefix' => $this->get('taskOption')->langConstPrefix,
                'params'          => $this->get('params'),
            ]
        );

        PluginHelper::importPlugin('task');

        try {
            $this->app->getDispatcher()->dispatch('onExecuteTask', $event);
        } catch (\Exception $e) {
            // Suppress the exception for now, we'll throw it again once it's safe
            $this->log(Text::sprintf('COM_SCHEDULER_TASK_ROUTINE_EXCEPTION', $e->getMessage()), 'error');
            $this->snapshot['exception'] = $e;
            $this->snapshot['status']    = Status::KNOCKOUT;
        }

        $resultSnapshot = $event->getResultSnapshot();

        $this->snapshot['taskEnd']     = microtime(true);
        $this->snapshot['netDuration'] = $this->snapshot['taskEnd'] - $this->snapshot['taskStart'];
        $this->snapshot                = array_merge($this->snapshot, $resultSnapshot);

        // @todo make the ExecRuleHelper usage less ugly, perhaps it should be composed into Task
        // Update object state.
        $this->set('last_execution', Factory::getDate('@' . (int) $this->snapshot['taskStart'])->toSql());
        $this->set('last_exit_code', $this->snapshot['status']);

        if ($this->snapshot['status'] !== Status::WILL_RESUME) {
            $this->set('next_execution', (new ExecRuleHelper($this->taskRegistry->toObject()))->nextExec());
            $this->set('times_executed', $this->get('times_executed') + 1);
        } else {
            /**
             * Resumable tasks need special handling.
             *
             * They are rescheduled as soon as possible to let their next step to be executed without
             * a very large temporal gap to the previous step.
             *
             * Moreover, the times executed does NOT increase for each step. It will increase once,
             * after the last step, when they return Status::OK.
             */
            $this->set('next_execution', Factory::getDate('now', 'UTC')->sub(new \DateInterval('PT1M'))->toSql());
        }

        // The only acceptable "successful" statuses are either clean exit or resuming execution.
        if (!\in_array($this->snapshot['status'], [Status::WILL_RESUME, Status::OK])) {
            $this->set('times_failed', $this->get('times_failed') + 1);
        }

        if (!$this->releaseLock()) {
            $this->snapshot['status'] = Status::NO_RELEASE;
        }

        $this->dispatchExitEvent();

        if (!empty($this->snapshot['exception'])) {
            throw $this->snapshot['exception'];
        }

        return $this->isSuccess();
    }

    /**
     * Get the task execution snapshot.
     * ! Access locations will need updates once a more robust Snapshot container is implemented.
     *
     * @return array
     *
     * @since 4.1.0
     */
    public function getContent(): array
    {
        return $this->snapshot;
    }

    /**
     * Acquire a pseudo-lock on the task record.
     * ! At the moment, this method is not used anywhere as task locks are already
     *   acquired when they're fetched. As such this method is not functional and should
     *   not be reviewed until it is updated.
     *
     * @return boolean
     *
     * @since 4.1.0
     * @throws \Exception
     */
    public function acquireLock(): bool
    {
        $db    = $this->db;
        $query = $db->getQuery(true);
        $id    = $this->get('id');
        $now   = Factory::getDate('now', 'GMT');

        $timeout          = ComponentHelper::getParams('com_scheduler')->get('timeout', 300);
        $timeout          = new \DateInterval(sprintf('PT%dS', $timeout));
        $timeoutThreshold = (clone $now)->sub($timeout)->toSql();
        $now              = $now->toSql();

        // @todo update or remove this method
        $query->update($db->quoteName('#__scheduler_tasks'))
            ->set('locked = :now')
            ->where($db->quoteName('id') . ' = :taskId')
            ->extendWhere(
                'AND',
                [
                    $db->quoteName('locked') . ' < :threshold',
                    $db->quoteName('locked') . 'IS NULL',
                ],
                'OR'
            )
            ->bind(':taskId', $id, ParameterType::INTEGER)
            ->bind(':now', $now)
            ->bind(':threshold', $timeoutThreshold);

        try {
            $db->lockTable('#__scheduler_tasks');
            $db->setQuery($query)->execute();
        } catch (\RuntimeException $e) {
            return false;
        } finally {
            $db->unlockTables();
        }

        if ($db->getAffectedRows() === 0) {
            return false;
        }

        $this->set('locked', $now);

        return true;
    }

    /**
     * Remove the pseudo-lock and optionally update the task record.
     *
     * @param   bool  $update  If true, the record is updated with the snapshot
     *
     * @return boolean
     *
     * @since 4.1.0
     * @throws \Exception
     */
    public function releaseLock(bool $update = true): bool
    {
        $db    = $this->db;
        $query = $db->getQuery(true);
        $id    = $this->get('id');

        $query->update($db->quoteName('#__scheduler_tasks', 't'))
            ->set('locked = NULL')
            ->where($db->quoteName('id') . ' = :taskId')
            ->where($db->quoteName('locked') . ' IS NOT NULL')
            ->bind(':taskId', $id, ParameterType::INTEGER);

        if ($update) {
            $exitCode      = $this->get('last_exit_code');
            $lastExec      = $this->get('last_execution');
            $nextExec      = $this->get('next_execution');
            $timesFailed   = $this->get('times_failed');
            $timesExecuted = $this->get('times_executed');

            $query->set(
                [
                    'last_exit_code = :exitCode',
                    'last_execution = :lastExec',
                    'next_execution = :nextExec',
                    'times_executed = :times_executed',
                    'times_failed = :times_failed',
                ]
            )
                ->bind(':exitCode', $exitCode, ParameterType::INTEGER)
                ->bind(':lastExec', $lastExec)
                ->bind(':nextExec', $nextExec)
                ->bind(':times_executed', $timesExecuted)
                ->bind(':times_failed', $timesFailed);
        }

        try {
            $db->setQuery($query)->execute();
        } catch (\RuntimeException $e) {
            return false;
        }

        if (!$db->getAffectedRows()) {
            return false;
        }

        $this->set('locked', null);

        return true;
    }

    /**
     * @param   string  $message   Log message
     * @param   string  $priority  Log level, defaults to 'info'
     *
     * @return  void
     *
     * @since 4.1.0
     * @throws InvalidArgumentException
     */
    public function log(string $message, string $priority = 'info'): void
    {
        $this->logger->log($priority, $message, ['category' => $this->logCategory]);
    }

    /**
     * Advance the task entry's next calculated execution, effectively skipping the current execution.
     *
     * @return void
     *
     * @since 4.1.0
     * @throws \Exception
     */
    public function skipExecution(): void
    {
        $db    = $this->db;
        $query = $db->getQuery(true);

        $id       = $this->get('id');
        $nextExec = (new ExecRuleHelper($this->taskRegistry->toObject()))->nextExec(true, true);

        $query->update($db->quoteName('#__scheduler_tasks', 't'))
            ->set('t.next_execution = :nextExec')
            ->where('t.id = :id')
            ->bind(':nextExec', $nextExec)
            ->bind(':id', $id);

        try {
            $db->setQuery($query)->execute();
        } catch (\RuntimeException $e) {
        }

        $this->set('next_execution', $nextExec);
    }

    /**
     * Handles task exit (dispatch event).
     *
     * @return void
     *
     * @since 4.1.0
     *
     * @throws \UnexpectedValueException|\BadMethodCallException
     */
    protected function dispatchExitEvent(): void
    {
        $exitCode  = $this->snapshot['status'] ?? 'NA';
        $eventName = self::EVENTS_MAP[$exitCode] ?? self::EVENTS_MAP['NA'];

        $event = AbstractEvent::create(
            $eventName,
            [
                'subject' => $this,
            ]
        );

        $this->app->getDispatcher()->dispatch($eventName, $event);
    }

    /**
     * Was the task successful?
     *
     * @return boolean  True if the task was successful.
     * @since 4.1.0
     */
    public function isSuccess(): bool
    {
        return \in_array(($this->snapshot['status'] ?? null), [Status::OK, Status::WILL_RESUME]);
    }

    /**
     * Set a task property. This method is a proxy to {@see Registry::set()}.
     *
     * @param   string   $path       Registry path of the task property.
     * @param   mixed    $value      The value to set to the property.
     * @param   ?string  $separator  The key separator.
     *
     * @return mixed|null
     *
     * @since 4.1.0
     */
    protected function set(string $path, $value, string $separator = null)
    {
        return $this->taskRegistry->set($path, $value, $separator);
    }

    /**
     * Get a task property. This method is a proxy to {@see Registry::get()}.
     *
     * @param   string  $path     Registry path of the task property.
     * @param   mixed   $default  Default property to return, if the actual value is null.
     *
     * @return mixed  The task property.
     *
     * @since 4.1.0
     */
    public function get(string $path, $default = null)
    {
        return $this->taskRegistry->get($path, $default);
    }

    /**
     * Static method to determine whether an enumerated task state (as a string) is valid.
     *
     * @param   string  $state  The task state (enumerated, as a string).
     *
     * @return boolean
     *
     * @since 4.1.0
     */
    public static function isValidState(string $state): bool
    {
        if (!is_numeric($state)) {
            return false;
        }

        // Takes care of interpreting as float/int
        $state += 0;

        return ArrayHelper::getValue(self::STATE_MAP, $state) !== null;
    }

    /**
     * Static method to determine whether a task id is valid. Note that this does not
     * validate ids against the database, but only verifies that an id may exist.
     *
     * @param   string  $id  The task id (as a string).
     *
     * @return boolean
     *
     * @since 4.1.0
     */
    public static function isValidId(string $id): bool
    {
        $id = is_numeric($id) ? ($id + 0) : $id;

        if (!\is_int($id) || $id <= 0) {
            return false;
        }

        return true;
    }
}
