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

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

use Joomla\CMS\Event\AbstractEvent;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\Model\AdminModel;
use Joomla\CMS\Table\Asset;
use Joomla\CMS\Table\Table;
use Joomla\Database\DatabaseDriver;
use Joomla\Database\Exception\QueryTypeAlreadyDefinedException;

/**
 * Table class for tasks scheduled through `com_scheduler`.
 * The type alias for Task table entries is `com_scheduler.task`.
 *
 * @since  4.1.0
 */
class TaskTable extends Table
{
	/**
	 * Indicates that columns fully support the NULL value in the database
	 *
	 * @var    boolean
	 * @since  4.1.1
	 */
	protected $_supportNullValue = true;

	/**
	 * Ensure params are json encoded by the bind method.
	 *
	 * @var    string[]
	 * @since  4.1.0
	 */
	protected $_jsonEncode = ['params', 'execution_rules', 'cron_rules'];

	/**
	 * The 'created' column.
	 *
	 * @var    string
	 * @since  4.1.0
	 */
	public $created;

	/**
	 * The 'title' column.
	 *
	 * @var    string
	 * @since  4.1.0
	 */
	public $title;

	/**
	 * @var    string
	 * @since  4.1.0
	 */
	public $typeAlias = 'com_scheduler.task';

	/**
	 * TaskTable constructor override, needed to pass the DB table name and primary key to {@see Table::__construct()}.
	 *
	 * @param  DatabaseDriver  $db  A database connector object.
	 *
	 * @since  4.1.0
	 */
	public function __construct(DatabaseDriver $db)
	{
		$this->setColumnAlias('published', 'state');

		parent::__construct('#__scheduler_tasks', 'id', $db);
	}

	/**
	 * Overloads {@see Table::check()} to perform sanity checks on properties and make sure they're
	 * safe to store.
	 *
	 * @return  boolean  True if checks pass.
	 *
	 * @since   4.1.0
	 * @throws  \Exception
	 */
	public function check(): bool
	{
		try
		{
			parent::check();
		}
		catch (\Exception $e)
		{
			Factory::getApplication()->enqueueMessage($e->getMessage());

			return false;
		}

		$this->title = htmlspecialchars_decode($this->title, ENT_QUOTES);

		// Set created date if not set.
		// ? Might not need since the constructor already sets this
		if (!(int) $this->created)
		{
			$this->created = Factory::getDate()->toSql();
		}

		// @todo : Add more checks if needed

		return true;
	}

	/**
	 * Override {@see Table::store()} to update null fields as a default, which is needed when DATETIME
	 * fields need to be updated to NULL. This override is needed because {@see AdminModel::save()} does not
	 * expose an option to pass true to Table::store(). Also ensures the `created` and `created_by` fields are
	 * set.
	 *
	 * @param   boolean  $updateNulls  True to update fields even if they're null.
	 *
	 * @return  boolean  True if successful.
	 *
	 * @since   4.1.0
	 * @throws  \Exception
	 */
	public function store($updateNulls = true): bool
	{
		$isNew = empty($this->getId());

		// Set creation date if not set for a new item.
		if ($isNew && empty($this->created))
		{
			$this->created = Factory::getDate()->toSql();
		}

		// Set `created_by` if not set for a new item.
		if ($isNew && empty($this->created_by))
		{
			$this->created_by = Factory::getApplication()->getIdentity()->id;
		}

		// @todo : Should we add modified, modified_by fields? [ ]

		return parent::store($updateNulls);
	}

	/**
	 * Returns the asset name of the entry as it appears in the {@see Asset} table.
	 *
	 * @return  string  The asset name.
	 *
	 * @since   4.1.0
	 */
	protected function _getAssetName(): string
	{
		$k = $this->_tbl_key;

		return 'com_scheduler.task.' . (int) $this->$k;
	}

	/**
	 * Override {@see Table::bind()} to bind some fields even if they're null given they're present in $src.
	 * This override is needed specifically for DATETIME fields, of which the `next_execution` field is updated to
	 * null if a task is configured to execute only on manual trigger.
	 *
	 * @param   array|object  $src     An associative array or object to bind to the Table instance.
	 * @param   array|string  $ignore  An optional array or space separated list of properties to ignore while binding.
	 *
	 * @return  boolean
	 *
	 * @since   4.1.0
	 */
	public function bind($src, $ignore = array()): bool
	{
		$fields = ['next_execution'];

		foreach ($fields as $field)
		{
			if (\array_key_exists($field, $src) && \is_null($src[$field]))
			{
				$this->$field = $src[$field];
			}
		}

		return parent::bind($src, $ignore);
	}

	/**
	 * Release pseudo-locks on a set of task records. If an empty set is passed, this method releases lock on its
	 * instance primary key, if available.
	 *
	 * @param   integer[]  $pks     An optional array of primary key values to update. If not set the instance property
	 *                              value is used.
	 * @param   ?int       $userId  ID of the user unlocking the tasks.
	 *
	 * @return  boolean  True on success; false if $pks is empty.
	 *
	 * @since   4.1.0
	 * @throws  QueryTypeAlreadyDefinedException|\UnexpectedValueException|\BadMethodCallException
	 */
	public function unlock(array $pks = [], ?int $userId = null): bool
	{
		// Pre-processing by observers
		$event = AbstractEvent::create(
			'onTaskBeforeUnlock',
			[
				'subject' => $this,
				'pks'     => $pks,
				'userId'  => $userId,
			]
		);

		$this->getDispatcher()->dispatch('onTaskBeforeUnlock', $event);

		// Some pre-processing before we can work with the keys.
		if (!empty($pks))
		{
			foreach ($pks as $key => $pk)
			{
				if (!\is_array($pk))
				{
					$pks[$key] = array($this->_tbl_key => $pk);
				}
			}
		}

		// If there are no primary keys set check to see if the instance key is set and use that.
		if (empty($pks))
		{
			$pk = [];

			foreach ($this->_tbl_keys as $key)
			{
				if ($this->$key)
				{
					$pk[$key] = $this->$key;
				}
				// We don't have a full primary key - return false.
				else
				{
					$this->setError(Text::_('JLIB_DATABASE_ERROR_NO_ROWS_SELECTED'));

					return false;
				}
			}

			$pks = [$pk];
		}

		$lockedField = $this->getColumnAlias('locked');

		foreach ($pks as $pk)
		{
			// Update the publishing state for rows with the given primary keys.
			$query = $this->_db->getQuery(true)
				->update($this->_tbl)
				->set($this->_db->quoteName($lockedField) . ' = NULL');

			// Build the WHERE clause for the primary keys.
			$this->appendPrimaryKeys($query, $pk);

			$this->_db->setQuery($query);

			try
			{
				$this->_db->execute();
			}
			catch (\RuntimeException $e)
			{
				$this->setError($e->getMessage());

				return false;
			}

			// If the Table instance value is in the list of primary keys that were set, set the instance.
			$ours = true;

			foreach ($this->_tbl_keys as $key)
			{
				if ($this->$key != $pk[$key])
				{
					$ours = false;
				}
			}

			if ($ours)
			{
				$this->$lockedField = null;
			}
		}

		// Pre-processing by observers
		$event = AbstractEvent::create(
			'onTaskAfterUnlock',
			[
				'subject' => $this,
				'pks'     => $pks,
				'userId'  => $userId,
			]
		);

		$this->getDispatcher()->dispatch('onTaskAfterUnlock', $event);

		return true;
	}
}
