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

use Joomla\CMS\Application\AdministratorApplication;
use Joomla\CMS\Component\ComponentHelper;
use Joomla\CMS\Event\AbstractEvent;
use Joomla\CMS\Factory;
use Joomla\CMS\Form\Form;
use Joomla\CMS\Form\FormFactoryInterface;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Log\Log;
use Joomla\CMS\MVC\Factory\MVCFactoryInterface;
use Joomla\CMS\MVC\Model\AdminModel;
use Joomla\CMS\Object\CMSObject;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\CMS\Table\Table;
use Joomla\Component\Scheduler\Administrator\Helper\ExecRuleHelper;
use Joomla\Component\Scheduler\Administrator\Helper\SchedulerHelper;
use Joomla\Component\Scheduler\Administrator\Table\TaskTable;
use Joomla\Component\Scheduler\Administrator\Task\TaskOption;
use Joomla\Database\ParameterType;
use Symfony\Component\OptionsResolver\Exception\AccessException;
use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException;
use Symfony\Component\OptionsResolver\Exception\UndefinedOptionsException;
use Symfony\Component\OptionsResolver\OptionsResolver;

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

/**
 * MVC Model to interact with the Scheduler DB.
 * Implements methods to add, remove, edit tasks.
 *
 * @since  4.1.0
 */
class TaskModel extends AdminModel
{
    /**
     * Maps logical states to their values in the DB
     * ? Do we end up using this?
     *
     * @var array
     * @since  4.1.0
     */
    protected const TASK_STATES = [
        'enabled'  => 1,
        'disabled' => 0,
        'trashed'  => -2,
    ];

    /**
     * The name of the  database table with task records.
     *
     * @var  string
     * @since 4.1.0
     */
    public const TASK_TABLE = '#__scheduler_tasks';

    /**
     * Prefix used with controller messages
     *
     * @var string
     * @since  4.1.0
     */
    protected $text_prefix = 'COM_SCHEDULER';

    /**
     * Type alias for content type
     *
     * @var string
     * @since  4.1.0
     */
    public $typeAlias = 'com_scheduler.task';

    /**
     * The Application object, for convenience
     *
     * @var AdministratorApplication $app
     * @since  4.1.0
     */
    protected $app;

    /**
     * The event to trigger before unlocking the data.
     *
     * @var    string
     * @since  4.1.0
     */
    protected $event_before_unlock = null;

    /**
     * The event to trigger after unlocking the data.
     *
     * @var    string
     * @since  4.1.0
     */
    protected $event_unlock = null;

    /**
     * TaskModel constructor. Needed just to set $app
     *
     * @param   array                      $config       An array of configuration options
     * @param   MVCFactoryInterface|null   $factory      The factory
     * @param   FormFactoryInterface|null  $formFactory  The form factory
     *
     * @since  4.1.0
     * @throws \Exception
     */
    public function __construct($config = [], MVCFactoryInterface $factory = null, FormFactoryInterface $formFactory = null)
    {
        $config['events_map'] = $config['events_map'] ?? [];

        $config['events_map'] = array_merge(
            [
                'save'     => 'task',
                'validate' => 'task',
                'unlock'   => 'task',
            ],
            $config['events_map']
        );

        if (isset($config['event_before_unlock'])) {
            $this->event_before_unlock = $config['event_before_unlock'];
        } elseif (empty($this->event_before_unlock)) {
            $this->event_before_unlock = 'onContentBeforeUnlock';
        }

        if (isset($config['event_unlock'])) {
            $this->event_unlock = $config['event_unlock'];
        } elseif (empty($this->event_unlock)) {
            $this->event_unlock = 'onContentUnlock';
        }

        $this->app = Factory::getApplication();

        parent::__construct($config, $factory, $formFactory);
    }

    /**
     * Fetches the form object associated with this model. By default,
     * loads the corresponding data from the DB and binds it with the form.
     *
     * @param   array  $data      Data that needs to go into the form
     * @param   bool   $loadData  Should the form load its data from the DB?
     *
     * @return Form|boolean  A JForm object on success, false on failure.
     *
     * @since  4.1.0
     * @throws \Exception
     */
    public function getForm($data = [], $loadData = true)
    {
        Form::addFieldPath(JPATH_ADMINISTRATOR . 'components/com_scheduler/src/Field');

        /**
         *  loadForm() (defined by FormBehaviourTrait) also loads the form data by calling
         *  loadFormData() : $data [implemented here] and binds it to the form by calling
         *  $form->bind($data).
         */
        $form = $this->loadForm('com_scheduler.task', 'task', ['control' => 'jform', 'load_data' => $loadData]);

        if (empty($form)) {
            return false;
        }

        $user = $this->app->getIdentity();

        // If new entry, set task type from state
        if ($this->getState('task.id', 0) === 0 && $this->getState('task.type') !== null) {
            $form->setValue('type', null, $this->getState('task.type'));
        }

        // @todo : Check if this is working as expected for new items (id == 0)
        if (!$user->authorise('core.edit.state', 'com_scheduler.task.' . $this->getState('task.id'))) {
            // Disable fields
            $form->setFieldAttribute('state', 'disabled', 'true');

            // No "hacking" ._.
            $form->setFieldAttribute('state', 'filter', 'unset');
        }

        return $form;
    }

    /**
     * Determine whether a record may be deleted taking into consideration
     * the user's permissions over the record.
     *
     * @param   object  $record  The database row/record in question
     *
     * @return  boolean  True if the record may be deleted
     *
     * @since  4.1.0
     * @throws \Exception
     */
    protected function canDelete($record): bool
    {
        // Record doesn't exist, can't delete
        if (empty($record->id)) {
            return false;
        }

        return $this->app->getIdentity()->authorise('core.delete', 'com_scheduler.task.' . $record->id);
    }

    /**
     * Populate the model state, we use these instead of toying with input or the global state
     *
     * @return  void
     *
     * @since  4.1.0
     * @throws \Exception
     */
    protected function populateState(): void
    {
        $app = $this->app;

        $taskId   = $app->getInput()->getInt('id');
        $taskType = $app->getUserState('com_scheduler.add.task.task_type');

        // @todo: Remove this. Get the option through a helper call.
        $taskOption = $app->getUserState('com_scheduler.add.task.task_option');

        $this->setState('task.id', $taskId);
        $this->setState('task.type', $taskType);
        $this->setState('task.option', $taskOption);

        // Load component params, though com_scheduler does not (yet) have any params
        $cParams = ComponentHelper::getParams($this->option);
        $this->setState('params', $cParams);
    }

    /**
     * Don't need to define this method since the parent getTable()
     * implicitly deduces $name and $prefix anyways. This makes the object
     * more transparent though.
     *
     * @param   string  $name     Name of the table
     * @param   string  $prefix   Class prefix
     * @param   array   $options  Model config array
     *
     * @return Table
     *
     * @since  4.1.0
     * @throws \Exception
     */
    public function getTable($name = 'Task', $prefix = 'Table', $options = []): Table
    {
        return parent::getTable($name, $prefix, $options);
    }

    /**
     * Fetches the data to be injected into the form
     *
     * @return object  Associative array of form data.
     *
     * @since  4.1.0
     * @throws \Exception
     */
    protected function loadFormData()
    {
        $data = $this->app->getUserState('com_scheduler.edit.task.data', []);

        // If the data from UserState is empty, we fetch it with getItem()
        if (empty($data)) {
            /** @var CMSObject $data */
            $data = $this->getItem();

            // @todo : further data processing goes here

            // For a fresh object, set exec-day and exec-time
            if (!($data->id ?? 0)) {
                $data->execution_rules['exec-day']  = gmdate('d');
                $data->execution_rules['exec-time'] = gmdate('H:i');
            }

            if ($data->next_execution) {
                $data->next_execution = Factory::getDate($data->next_execution);
                $data->next_execution->setTimezone(new \DateTimeZone($this->app->get('offset', 'UTC')));
                $data->next_execution = $data->next_execution->toSql(true);
            }

            if ($data->last_execution) {
                $data->last_execution = Factory::getDate($data->last_execution);
                $data->last_execution->setTimezone(new \DateTimeZone($this->app->get('offset', 'UTC')));
                $data->last_execution = $data->last_execution->toSql(true);
            }
        }

        // Let plugins manipulate the data
        $this->preprocessData('com_scheduler.task', $data, 'task');

        return $data;
    }

    /**
     * Overloads the parent getItem() method.
     *
     * @param   integer  $pk  Primary key
     *
     * @return  object|boolean  Object on success, false on failure
     *
     * @since  4.1.0
     * @throws \Exception
     */
    public function getItem($pk = null)
    {
        $item = parent::getItem($pk);

        if (!\is_object($item)) {
            return false;
        }

        // Parent call leaves `execution_rules` and `cron_rules` JSON encoded
        $item->set('execution_rules', json_decode($item->get('execution_rules', '')));
        $item->set('cron_rules', json_decode($item->get('cron_rules', '')));

        $taskOption = SchedulerHelper::getTaskOptions()->findOption(
            ($item->id ?? 0) ? ($item->type ?? 0) : $this->getState('task.type')
        );

        $item->set('taskOption', $taskOption);

        return $item;
    }

    /**
     * Get a task from the database, only if an exclusive "lock" on the task can be acquired.
     * The method supports options to customise the limitations on the fetch.
     *
     * @param   array  $options  Array with options to fetch the task:
     *                           1. `id`: Optional id of the task to fetch.
     *                           2. `allowDisabled`: If true, disabled tasks can also be fetched.
     *                           (default: false)
     *                           3. `bypassScheduling`: If true, tasks that are not due can also be
     *                           fetched. Should only be true if an `id` is targeted instead of the
     *                           task queue. (default: false)
     *                           4. `allowConcurrent`: If true, fetches even when another task is
     *                           running ('locked'). (default: false)
     *                           5. `includeCliExclusive`: If true, can also fetch CLI exclusive tasks. (default: true)
     *
     * @return ?\stdClass  Task entry as in the database.
     *
     * @since   4.1.0
     * @throws UndefinedOptionsException|InvalidOptionsException
     * @throws \RuntimeException
     */
    public function getTask(array $options = []): ?\stdClass
    {
        $resolver = new OptionsResolver();

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

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

        $db  = $this->getDatabase();
        $now = Factory::getDate()->toSql();

        // Get lock on the table to help with concurrency issues
        $db->lockTable(self::TASK_TABLE);

        // If concurrency is not allowed, we only get a task if another one does not have a "lock"
        if (!$options['allowConcurrent']) {
            // Get count of locked (presumed running) tasks
            $lockCountQuery = $db->getQuery(true)
                ->from($db->quoteName(self::TASK_TABLE))
                ->select('COUNT(id)')
                ->where($db->quoteName('locked') . ' IS NOT NULL');

            try {
                $runningCount = $db->setQuery($lockCountQuery)->loadResult();
            } catch (\RuntimeException $e) {
                $db->unlockTables();

                return null;
            }

            if ($runningCount !== 0) {
                $db->unlockTables();

                return null;
            }
        }

        $lockQuery = $db->getQuery(true);

        $lockQuery->update($db->quoteName(self::TASK_TABLE))
            ->set($db->quoteName('locked') . ' = :now1')
            ->bind(':now1', $now);

        // Array of all active routine ids
        $activeRoutines = array_map(
            static function (TaskOption $taskOption): string {
                return $taskOption->id;
            },
            SchedulerHelper::getTaskOptions()->options
        );

        // "Orphaned" tasks are not a part of the task queue!
        $lockQuery->whereIn($db->quoteName('type'), $activeRoutines, ParameterType::STRING);

        // If directed, exclude CLI exclusive tasks
        if (!$options['includeCliExclusive']) {
            $lockQuery->where($db->quoteName('cli_exclusive') . ' = 0');
        }

        if (!$options['bypassScheduling']) {
            $lockQuery->where($db->quoteName('next_execution') . ' <= :now2')
                ->bind(':now2', $now);
        }

        if ($options['allowDisabled']) {
            $lockQuery->whereIn($db->quoteName('state'), [0, 1]);
        } else {
            $lockQuery->where($db->quoteName('state') . ' = 1');
        }

        if ($options['id'] > 0) {
            $lockQuery->where($db->quoteName('id') . ' = :taskId')
                ->bind(':taskId', $options['id'], ParameterType::INTEGER);
        } else {
            // Pick from the front of the task queue if no 'id' is specified
            // Get the id of the next task in the task queue
            $idQuery = $db->getQuery(true)
                ->from($db->quoteName(self::TASK_TABLE))
                ->select($db->quoteName('id'))
                ->where($db->quoteName('state') . ' = 1')
                ->order($db->quoteName('priority') . ' DESC')
                ->order($db->quoteName('next_execution') . ' ASC')
                ->setLimit(1);

            try {
                $ids = $db->setQuery($idQuery)->loadColumn();
            } catch (\RuntimeException $e) {
                $db->unlockTables();

                return null;
            }

            if (count($ids) === 0) {
                $db->unlockTables();

                return null;
            }

            $lockQuery->whereIn($db->quoteName('id'), $ids);
        }

        try {
            $db->setQuery($lockQuery)->execute();
        } catch (\RuntimeException $e) {
        } finally {
            $affectedRows = $db->getAffectedRows();

            $db->unlockTables();
        }

        if ($affectedRows != 1) {
            /*
             // @todo
            // ? Fatal failure handling here?
            // ! Question is, how? If we check for tasks running beyond there time here, we have no way of
            //  ! what's already been notified (since we're not auto-unlocking/recovering tasks anymore).
            // The solution __may__ be in a "last_successful_finish" (or something) column.
            */

            return null;
        }

        $getQuery = $db->getQuery(true);

        $getQuery->select('*')
            ->from($db->quoteName(self::TASK_TABLE))
            ->where($db->quoteName('locked') . ' = :now')
            ->bind(':now', $now);

        $task = $db->setQuery($getQuery)->loadObject();

        $task->execution_rules = json_decode($task->execution_rules);
        $task->cron_rules      = json_decode($task->cron_rules);

        $task->taskOption = SchedulerHelper::getTaskOptions()->findOption($task->type);

        return $task;
    }

    /**
     * Set up an {@see OptionsResolver} to resolve options compatible with the {@see GetTask()} method.
     *
     * @param   OptionsResolver  $resolver  The {@see OptionsResolver} instance to set up.
     *
     * @return OptionsResolver
     *
     * @since 4.1.0
     * @throws AccessException
     */
    public static function configureTaskGetterOptions(OptionsResolver $resolver): OptionsResolver
    {
        $resolver->setDefaults(
            [
                'id'                  => 0,
                'allowDisabled'       => false,
                'bypassScheduling'    => false,
                'allowConcurrent'     => false,
                'includeCliExclusive' => true,
            ]
        )
            ->setAllowedTypes('id', 'numeric')
            ->setAllowedTypes('allowDisabled', 'bool')
            ->setAllowedTypes('bypassScheduling', 'bool')
            ->setAllowedTypes('allowConcurrent', 'bool')
            ->setAllowedTypes('includeCliExclusive', 'bool');

        return $resolver;
    }

    /**
     * @param   array  $data  The form data
     *
     * @return  boolean  True on success, false on failure
     *
     * @since  4.1.0
     * @throws \Exception
     */
    public function save($data): bool
    {
        $id    = (int) ($data['id'] ?? $this->getState('task.id'));
        $isNew = $id === 0;

        // Clean up execution rules
        $data['execution_rules'] = $this->processExecutionRules($data['execution_rules']);

        // If a new entry, we'll have to put in place a pseudo-last_execution
        if ($isNew) {
            $basisDayOfMonth           = $data['execution_rules']['exec-day'];
            [$basisHour, $basisMinute] = explode(':', $data['execution_rules']['exec-time']);

            $data['last_execution'] = Factory::getDate('now', 'GMT')->format('Y-m')
                . "-$basisDayOfMonth $basisHour:$basisMinute:00";
        } else {
            $data['last_execution'] = $this->getItem($id)->last_execution;
        }

        // Build the `cron_rules` column from `execution_rules`
        $data['cron_rules'] = $this->buildExecutionRules($data['execution_rules']);

        // `next_execution` would be null if scheduling is disabled with the "manual" rule!
        $data['next_execution'] = (new ExecRuleHelper($data))->nextExec();

        if ($isNew) {
            $data['last_execution'] = null;
        }

        // If no params, we set as empty array.
        // ? Is this the right place to do this
        $data['params'] = $data['params'] ?? [];

        // Parent method takes care of saving to the table
        return parent::save($data);
    }

    /**
     * Clean up and standardise execution rules
     *
     * @param   array  $unprocessedRules  The form data [? can just replace with execution_interval]
     *
     * @return array  Processed rules
     *
     * @since  4.1.0
     */
    private function processExecutionRules(array $unprocessedRules): array
    {
        $executionRules = $unprocessedRules;

        $ruleType       = $executionRules['rule-type'];
        $retainKeys     = ['rule-type', $ruleType, 'exec-day', 'exec-time'];
        $executionRules = array_intersect_key($executionRules, array_flip($retainKeys));

        // Default to current date-time in UTC/GMT as the basis
        $executionRules['exec-day']  = $executionRules['exec-day'] ?: (string) gmdate('d');
        $executionRules['exec-time'] = $executionRules['exec-time'] ?: (string) gmdate('H:i');

        // If custom ruleset, sort it
        // ? Is this necessary
        if ($ruleType === 'cron-expression') {
            foreach ($executionRules['cron-expression'] as &$values) {
                sort($values);
            }
        }

        return $executionRules;
    }

    /**
     * Private method to build execution expression from input execution rules.
     * This expression is used internally to determine execution times/conditions.
     *
     * @param   array  $executionRules  Execution rules from the Task form, post-processing.
     *
     * @return array
     *
     * @since  4.1.0
     * @throws \Exception
     */
    private function buildExecutionRules(array $executionRules): array
    {
        // Maps interval strings, use with sprintf($map[intType], $interval)
        $intervalStringMap = [
            'minutes' => 'PT%dM',
            'hours'   => 'PT%dH',
            'days'    => 'P%dD',
            'months'  => 'P%dM',
            'years'   => 'P%dY',
        ];

        $ruleType        = $executionRules['rule-type'];
        $ruleClass       = strpos($ruleType, 'interval') === 0 ? 'interval' : $ruleType;
        $buildExpression = '';

        if ($ruleClass === 'interval') {
            // Rule type for intervals interval-<minute/hours/...>
            $intervalType    = explode('-', $ruleType)[1];
            $interval        = $executionRules["interval-$intervalType"];
            $buildExpression = sprintf($intervalStringMap[$intervalType], $interval);
        }

        if ($ruleClass === 'cron-expression') {
            // ! custom matches are disabled in the form
            $matches         = $executionRules['cron-expression'];
            $buildExpression .= $this->wildcardIfMatch($matches['minutes'], range(0, 59), true);
            $buildExpression .= ' ' . $this->wildcardIfMatch($matches['hours'], range(0, 23), true);
            $buildExpression .= ' ' . $this->wildcardIfMatch($matches['days_month'], range(1, 31), true);
            $buildExpression .= ' ' . $this->wildcardIfMatch($matches['months'], range(1, 12), true);
            $buildExpression .= ' ' . $this->wildcardIfMatch($matches['days_week'], range(0, 6), true);
        }

        return [
            'type' => $ruleClass,
            'exp'  => $buildExpression,
        ];
    }

    /**
     * This method releases "locks" on a set of tasks from the database.
     * These locks are pseudo-locks that are used to keep a track of running tasks. However, they require require manual
     * intervention to release these locks in cases such as when a task process crashes, leaving the task "locked".
     *
     * @param   array  $pks  A list of the primary keys to unlock.
     *
     * @return  boolean  True on success.
     *
     * @since   4.1.0
     * @throws \RuntimeException|\UnexpectedValueException|\BadMethodCallException
     */
    public function unlock(array &$pks): bool
    {
        /** @var TaskTable $table */
        $table = $this->getTable();

        $user = Factory::getApplication()->getIdentity();

        $context = $this->option . '.' . $this->name;

        // Include the plugins for the change of state event.
        PluginHelper::importPlugin($this->events_map['unlock']);

        // Access checks.
        foreach ($pks as $i => $pk) {
            $table->reset();

            if ($table->load($pk)) {
                if (!$this->canEditState($table)) {
                    // Prune items that you can't change.
                    unset($pks[$i]);
                    Log::add(Text::_('JLIB_APPLICATION_ERROR_EDITSTATE_NOT_PERMITTED'), Log::WARNING, 'jerror');

                    return false;
                }

                // Prune items that are already at the given state.
                $lockedColumnName = $table->getColumnAlias('locked');

                if (property_exists($table, $lockedColumnName) && \is_null($table->get($lockedColumnName))) {
                    unset($pks[$i]);
                }
            }
        }

        // Check if there are items to change.
        if (!\count($pks)) {
            return true;
        }

        $event = AbstractEvent::create(
            $this->event_before_unlock,
            [
                'subject' => $this,
                'context' => $context,
                'pks'     => $pks,
            ]
        );

        try {
            Factory::getApplication()->getDispatcher()->dispatch($this->event_before_unlock, $event);
        } catch (\RuntimeException $e) {
            $this->setError($e->getMessage());

            return false;
        }

        // Attempt to unlock the records.
        if (!$table->unlock($pks, $user->id)) {
            $this->setError($table->getError());

            return false;
        }

        // Trigger the after unlock event
        $event = AbstractEvent::create(
            $this->event_unlock,
            [
                'subject' => $this,
                'context' => $context,
                'pks'     => $pks,
            ]
        );

        try {
            Factory::getApplication()->getDispatcher()->dispatch($this->event_unlock, $event);
        } catch (\RuntimeException $e) {
            $this->setError($e->getMessage());

            return false;
        }

        // Clear the component's cache
        $this->cleanCache();

        return true;
    }

    /**
     * Determine if an array is populated by all its possible values by comparison to a reference array, if found a
     * match a wildcard '*' is returned.
     *
     * @param   array  $target       The target array
     * @param   array  $reference    The reference array, populated by the complete set of possible values in $target
     * @param   bool   $targetToInt  If true, converts $target array values to integers before comparing
     *
     * @return string  A wildcard string if $target is fully populated, else $target itself.
     *
     * @since  4.1.0
     */
    private function wildcardIfMatch(array $target, array $reference, bool $targetToInt = false): string
    {
        if ($targetToInt) {
            $target = array_map(
                static function (string $x): int {
                    return (int) $x;
                },
                $target
            );
        }

        $isMatch = array_diff($reference, $target) === [];

        return $isMatch ? "*" : implode(',', $target);
    }

    /**
     * Method to allow derived classes to preprocess the form.
     *
     * @param   Form    $form   A Form object.
     * @param   mixed   $data   The data expected for the form.
     * @param   string  $group  The name of the plugin group to import (defaults to "content").
     *
     * @return  void
     *
     * @since   4.1.0
     * @throws  \Exception if there is an error in the form event.
     */
    protected function preprocessForm(Form $form, $data, $group = 'content'): void
    {
        // Load the 'task' plugin group
        PluginHelper::importPlugin('task');

        // Let the parent method take over
        parent::preprocessForm($form, $data, $group);
    }
}
