<?php
/**
* @version	$Id: cron_helper.php 16513 2017-01-20 14:10:53Z alex $
* @package	In-Portal
* @copyright	Copyright (C) 1997 - 2009 Intechnic. All rights reserved.
* @license      GNU/GPL
* In-Portal is Open Source software.
* This means that this software may have been modified pursuant
* the GNU General Public License, and as distributed it includes
* or is derivative of works licensed under the GNU General Public License
* or other free or open source software licenses.
* See http://www.in-portal.org/license for copyright notices and details.
*/

defined('FULL_PATH') or die('restricted access!');

class kCronHelper extends kHelper {

	const COMMON = 0;
	const MINUTE = 1;
	const HOUR = 2;
	const DAY = 3;
	const MONTH = 4;
	const WEEKDAY = 5;

	/**
	 * Defines possible cron fields and their matching priority
	 *
	 * @var Array
	 * @access protected
	 */
	protected $fieldTypes = Array (self::MONTH, self::DAY, self::WEEKDAY, self::HOUR, self::MINUTE);

	protected $commonSettings = Array (
		'* * * * *' => 'la_opt_CronEveryMinute',
		'*/5 * * * *' => 'la_opt_CronEveryFiveMinutes',
		'0,30 * * * *' => 'la_opt_CronTwiceAnHour',
		'0 * * * *' => 'la_opt_CronOnceAnHour',
		'0 0,12 * * *' => 'la_opt_CronTwiceADay',
		'0 0 * * *' => 'la_opt_CronOnceADay',
		'0 0 * * 0' => 'la_opt_CronOnceAWeek',
		'0 0 1,15 * *' => 'la_opt_CronTwiceAMonth',
		'0 0 1 * *' => 'la_opt_CronOnceAMonth',
		'0 0 1 1 *' => 'la_opt_CronOnceAYear',
	);

	protected $minuteSettings = Array (
		'*' => 'la_opt_CronEveryMinute',
		'*/2' => 'la_opt_CronEveryOtherMinute',
		'*/5' => 'la_opt_CronEveryFiveMinutes',
		'*/10' => 'la_opt_CronEveryTenMinutes',
		'*/15' => 'la_opt_CronEveryFifteenMinutes',
		'0,30' => 'la_opt_CronEveryThirtyMinutes',
		'--' => 'la_opt_CronMinutes',
		// minutes added dynamically later
	);

	protected $hourSettings = Array (
		'*' => 'la_opt_CronEveryHour',
		'*/2' => 'la_opt_CronEveryOtherHour',
		'*/3' => 'la_opt_CronEveryThreeHours',
		'*/4' => 'la_opt_CronEveryFourHours',
		'*/6' => 'la_opt_CronEverySixHours',
		'0,12' => 'la_opt_CronEveryTwelveHours',
		'--' => 'la_opt_CronHours',
		// hours added dynamically later
	);

	protected $daySettings = Array (
		'*' => 'la_opt_CronEveryDay',
		'*/2' => 'la_opt_CronEveryOtherDay',
		'1,15' => 'la_opt_CronTwiceAMonth',
		'--' => 'la_opt_CronDays',
		// days added dynamically later
	);

	protected $monthSettings = Array (
		'*' => 'la_opt_CronEveryMonth',
		'*/2' => 'la_opt_CronEveryOtherMonth',
		'*/4' => 'la_opt_CronEveryThreeMonths',
		'1,7' => 'la_opt_CronEverySixMonths',
		'--' => 'la_opt_CronMonths',
		'1' => 'la_opt_January',
		'2' => 'la_opt_February',
		'3' => 'la_opt_March',
		'4' => 'la_opt_April',
		'5' => 'la_opt_May',
		'6' => 'la_opt_June',
		'7' => 'la_opt_July',
		'8' => 'la_opt_August',
		'9' => 'la_opt_September',
		'10' => 'la_opt_October',
		'11' => 'la_opt_November',
		'12' => 'la_opt_December',
	);

	protected $weekdaySettings = Array (
		'*' => 'la_opt_CronEveryWeekday',
		'1-5' => 'la_opt_CronMondayThroughFriday',
		'0,6' => 'la_opt_CronSaturdayAndSunday',
		'1,3,5' => 'la_opt_CronMondayWednesdayAndFriday',
		'2,4' => 'la_opt_CronTuesdayAndThursday',
		'--' => 'la_opt_CronWeekdays',
		'0' => 'la_opt_Sunday',
		'1' => 'la_opt_Monday',
		'2' => 'la_opt_Tuesday',
		'3' => 'la_opt_Wednesday',
		'4' => 'la_opt_Thursday',
		'5' => 'la_opt_Friday',
		'6' => 'la_opt_Saturday',
	);

	/**
	 * Returns possible field options by type
	 *
	 * @param int $field_type
	 * @return Array
	 */
	public function getOptions($field_type)
	{
		$mapping = Array (
			self::COMMON => $this->commonSettings,
			self::MINUTE => $this->minuteSettings,
			self::HOUR => $this->hourSettings,
			self::DAY => $this->daySettings,
			self::MONTH => $this->monthSettings,
			self::WEEKDAY => $this->weekdaySettings,
		);

		/** @var Array $ret */
		$ret = $mapping[$field_type];

		foreach ($ret as $option_key => $option_title) {
			$option_title = substr($option_title, 0, 1) == '+' ? substr($option_title, 1) : $this->Application->Phrase($option_title);
			$ret[$option_key] = $option_title;

			if ( "$option_key" !== '--' ) {
				$ret[$option_key] .= ' (' . $option_key . ')';
			}
		}

		if ( $field_type == self::MINUTE ) {
			for ($i = 0; $i <= 59; $i++) {
				$ret[$i] = ':' . str_pad($i, 2, '0', STR_PAD_LEFT) . ' (' . $i . ')';
			}
		}
		elseif ( $field_type == self::HOUR ) {
			/** @var LanguagesItem $language */
			$language = $this->Application->recallObject('lang.current');

			$short_time_format = str_replace(':s', '', $language->GetDBField('TimeFormat'));

			for ($i = 0; $i <= 23; $i++) {
				$ret[$i] = adodb_date($short_time_format, adodb_mktime($i, 0, 0)) . ' (' . $i . ')';
			}
		}
		elseif ( $field_type == self::DAY ) {
			/** @var kMultiLanguageHelper $ml_helper */
			$ml_helper = $this->Application->recallObject('kMultiLanguageHelper');

			$forms = Array (
				'phrase1' => 'la_NumberSuffixSt', 'phrase2' => 'la_NumberSuffixNd', 'phrase3' => 'la_NumberSuffixRd',
				'phrase4' => 'la_NumberSuffixTh', 'phrase5' => 'la_NumberSuffixTh'
			);

			for ($i = 1; $i <= 31; $i++) {
				$ret[$i] = $i . $ml_helper->getPluralPhrase($i, $forms) . ' (' . $i . ')';
			}
		}

		return $ret;
	}

	/**
	 * Returns field name by type
	 *
	 * @param int $field_type
	 * @param string $field_prefix
	 * @return string
	 * @access protected
	 */
	protected function _getFieldNameByType($field_type, $field_prefix)
	{
		$field_mapping = Array (
			self::MINUTE => 'Minute',
			self::HOUR => 'Hour',
			self::DAY => 'Day',
			self::MONTH => 'Month',
			self::WEEKDAY => 'Weekday',
		);

		return $field_prefix . $field_mapping[$field_type];
	}

	/**
	 * Creates virtual fields for given unit
	 *
	 * @param string $prefix
	 * @param string $field_prefix
	 * @return void
	 * @access public
	 */
	public function initUnit($prefix, $field_prefix = '')
	{
		$virtual_fields = $this->Application->getUnitOption($prefix, 'VirtualFields', Array ());

		$virtual_fields[$field_prefix . 'CommonHints'] = Array (
			'type' => 'string',
			'formatter' => 'kOptionsFormatter', 'options' => $this->getOptions(self::COMMON),
			'default' => ''
		);

		foreach ($this->fieldTypes as $field_type) {
			$field_name = $this->_getFieldNameByType($field_type, $field_prefix);
			$virtual_fields[$field_name] = Array ('type' => 'string', 'max_len' => 30, 'default' => '*');

			$virtual_fields[$field_name . 'Hints'] = Array (
				'type' => 'string',
				'formatter' => 'kOptionsFormatter', 'options' => $this->getOptions($field_type),
				'default' => ''
			);
		}

		$this->Application->setUnitOption($prefix, 'VirtualFields', $virtual_fields);
	}

	/**
	 * Loads schedule values from database into virtual fields
	 *
	 * @param kDBItem $object
	 * @param string $field_prefix
	 */
	public function load(kDBItem $object, $field_prefix = '')
	{
		$combined_value = explode(' ', $object->GetDBField($field_prefix));

		foreach ($this->fieldTypes as $field_type) {
			$field_name = $this->_getFieldNameByType($field_type, $field_prefix);
			$object->SetDBField($field_name, $combined_value[$field_type - 1]);
		}
	}

	/**
	 * Validates schedule values and saves them to database
	 *
	 * @param kDBItem $object
	 * @param string $field_prefix
	 * @return bool
	 * @access public
	 */
	public function validateAndSave(kDBItem $object, $field_prefix = '')
	{
		$validated = true;
		$combined_value = Array ();
		$cron_field = new kCronField();

		foreach ($this->fieldTypes as $field_type) {
			$field_name = $this->_getFieldNameByType($field_type, $field_prefix);
			$value = preg_replace('/\s+/s', '', mb_strtoupper($object->GetDBField($field_name)));

			if ( $cron_field->validate($field_type, $value) ) {
				$object->SetDBField($field_name, $value);
			}
			else {
				$validated = false;
				$object->SetError($field_name, 'invalid_format');
			}

			$combined_value[$field_type] = $value;
		}

		ksort($combined_value);
		$object->SetDBField($field_prefix, implode(' ', $combined_value));

		return $validated;
	}

	/**
	 * Replaces aliases in the field
	 *
	 * @param int $field_type
	 * @param string $value
	 * @return string
	 * @access public
	 */
	public static function replaceAliases($field_type, $value)
	{
		$replacements = Array ();
		$value = mb_strtolower($value);

		if ( $field_type == self::MONTH ) {
			$replacements = Array (
				'jan' => 1, 'feb' => 2, 'mar' => 3, 'apr' => 4, 'may' => 5, 'jun' => 6,
				'jul' => 7, 'aug' => 8, 'sep' => 9, 'oct' => 10, 'nov' => 11, 'dec' => 12,
			);
		}
		elseif ( $field_type == self::WEEKDAY ) {
			$replacements = Array ('sun' => 0, 'mon' => 1, 'tue' => 2, 'wed' => 3, 'thu' => 4, 'fri' => 5, 'sat' => 6);
		}

		if ( $replacements ) {
			$value = str_replace(array_keys($replacements), array_values($replacements), $value);
		}

		return $value;
	}

	/**
	 * Returns next (after given one or now) timestamp matching given cron expression
	 *
	 * @param string $expression
	 * @param int $date
	 * @param bool $inverse
	 * @param bool $allow_current_date
	 * @return int
	 * @access public
	 * @throws RuntimeException
	 */
	public function getMatch($expression, $date = NULL, $inverse = false, $allow_current_date = false)
	{
		if ( !isset($date) ) {
			$date = TIMENOW;
		}

		$next_run = strtotime('-' . (int)adodb_date('s', $date) . ' seconds', $date);
		$expression_parts = explode(' ', $expression);

		$cron_field = new kCronField();

		// set a hard limit to bail on an impossible date
		for ($i = 0; $i < 1000; $i++) {
			foreach ($this->fieldTypes as $field_type) {
				$matched = false;
				$part = $expression_parts[$field_type - 1];

				// check if this is singular or a list
				if ( strpos($part, ',') === false ) {
					$matched = $cron_field->match($field_type, $next_run, $part);
				}
				else {
					$rules = explode(',', $part);

					foreach ($rules as $rule) {
						if ( $cron_field->match($field_type, $next_run, $rule) ) {
							$matched = true;
							break;
						}
					}
				}

				// if the field is not matched, then start over
				if ( !$matched ) {
					$next_run = $cron_field->increment($field_type, $next_run, $inverse);
					continue 2;
				}
			}

			// Skip this match if needed
			if ( (!$allow_current_date && $next_run == $date) ) {
				$next_run = $cron_field->increment(self::MINUTE, $next_run, $inverse);
				continue;
			}

			return $next_run;
		}

		throw new RuntimeException('Impossible CRON expression');
	}
}


class kCronField extends kBase {

	/**
	 * Validates field value
	 *
	 * @param int $field_type
	 * @param string $value
	 * @param bool $asterisk_allowed
	 * @return bool
	 * @access public
	 */
	public function validate($field_type, $value, $asterisk_allowed = true)
	{
		$rules = explode(',', kCronHelper::replaceAliases($field_type, $value));

		foreach ($rules as $rule) {
			if ( $this->_isIncrementRule($rule) ) {
				if ( !$this->_validateIncrementRule($field_type, $rule) ) {
					return false;
				}
			}
			elseif ( $this->_isRangeRule($rule) ) {
				if ( !$this->_validateRangeRule($field_type, $rule) ) {
					return false;
				}
			}
			elseif ( !$this->_validateNumberRule($field_type, $rule, $asterisk_allowed) ) {
				return false;
			}
		}

		return true;
	}

	/**
	 * Determines if expression is range
	 *
	 * @param string $rule
	 * @return bool
	 * @access protected
	 */
	protected function _isRangeRule($rule)
	{
		return strpos($rule, '-') !== false;
	}

	/**
	 * Validates range rule
	 *
	 * @param int $field_type
	 * @param string $rule
	 * @return bool
	 * @access protected
	 */
	protected function _validateRangeRule($field_type, $rule)
	{
		$parts = explode('-', $rule);

		if ( count($parts) != 2 ) {
			return false;
		}

		$min_value = $parts[0];
		$max_value = $parts[1];

		if ( !$this->_validateNumberRule($field_type, $min_value) || !$this->_validateNumberRule($field_type, $max_value) || $min_value >= $max_value ) {
			return false;
		}

		return true;
	}

	/**
	 * Determines if expression is increment
	 *
	 * @param string $rule
	 * @return bool
	 * @access protected
	 */
	protected function _isIncrementRule($rule)
	{
		return strpos($rule, '/') !== false;
	}

	/**
	 * Validates increment rule
	 *
	 * @param int $field_type
	 * @param string $rule
	 * @return bool
	 * @access protected
	 */
	protected function _validateIncrementRule($field_type, $rule)
	{
		$parts = explode('/', $rule);

		if ( count($parts) != 2 ) {
			return false;
		}

		$interval = $parts[0];
		$increment = $parts[1];

		if ( $this->_isRangeRule($interval) ) {
			if ( !$this->_validateRangeRule($field_type, $interval) ) {
				return false;
			}
		}
		elseif ( !$this->_validateNumberRule($field_type, $interval, true) ) {
			return false;
		}

		if ( !$this->_validateNumberRule($field_type, $increment) ) {
			return false;
		}

		return true;
	}

	/**
	 * Validates, that number within range OR an asterisk is given
	 *
	 * @param int $field_type
	 * @param string $rule
	 * @param bool $asterisk_allowed
	 * @return bool
	 * @access protected
	 */
	protected function _validateNumberRule($field_type, $rule, $asterisk_allowed = false)
	{
		if ( "$rule" === '*' ) {
			return $asterisk_allowed;
		}

		$int_rule = (int)$rule;

		if ( !is_numeric($rule) || "$int_rule" !== "$rule" ) {
			// not integer
			return false;
		}

		$range_mapping = Array (
			kCronHelper::MINUTE => Array ('from' => 0, 'to' => 59),
			kCronHelper::HOUR => Array ('from' => 0, 'to' => 23),
			kCronHelper::DAY => Array ('from' => 1, 'to' => 31),
			kCronHelper::MONTH => Array ('from' => 1, 'to' => 12),
			kCronHelper::WEEKDAY => Array ('from' => 0, 'to' => 7),
		);

		return $int_rule >= $range_mapping[$field_type]['from'] && $int_rule <= $range_mapping[$field_type]['to'];
	}

	/**
	 * Tries to match given date to given expression
	 *
	 * @param int $field_type
	 * @param int $date
	 * @param string $rule
	 * @return bool
	 * @access public
	 */
	public function match($field_type, $date, $rule)
	{
		$date_part = $this->_getDatePart($field_type, $date, $rule);

		if ( $this->_isIncrementRule($rule) ) {
			return $this->_isInIncrement($date_part, $rule);
		}
		elseif ( $this->_isRangeRule($rule) ) {
			return $this->_isInRange($date_part, $rule);
		}

		return $rule == '*' || $date_part == $rule;
	}

	/**
	 * Returns only part, needed based on field type of date in timestamp
	 *
	 * @param int $field_type
	 * @param int $date
	 * @param string $rule
	 * @return int
	 * @access protected
	 */
	protected function _getDatePart($field_type, $date, $rule)
	{
		$mapping = Array (
			kCronHelper::MINUTE => 'i',
			kCronHelper::HOUR => 'G',
			kCronHelper::DAY => 'j',
			kCronHelper::MONTH => 'n',
			kCronHelper::WEEKDAY => 'N',
		);

		if ( $field_type == kCronHelper::WEEKDAY ) {
			// Test to see which Sunday to use -- 0 == 7 == Sunday
			$mapping[$field_type] = in_array(7, str_split($rule)) ? 'N' : 'w';
		}

		return (int)adodb_date($mapping[$field_type], $date);
	}

	/**
	 * Test if a value is within a range
	 *
	 * @param string $date_value Set date value
	 * @param string $rule Value to test
	 * @return bool
	 * @access protected
	 */
	protected function _isInRange($date_value, $rule)
	{
		$parts = array_map('trim', explode('-', $rule, 2));

		return $date_value >= $parts[0] && $date_value <= $parts[1];
	}

	/**
	 * Test if a value is within an increments of ranges (offset[-to]/step size)
	 *
	 * @param string $date_value Set date value
	 * @param string $rule Value to test
	 * @return bool
	 * @access protected
	 */
	protected function _isInIncrement($date_value, $rule)
	{
		$parts = array_map('trim', explode('/', $rule, 2));
		$stepSize = isset($parts[1]) ? $parts[1] : 0;

		if ( $parts[0] == '*' || $parts[0] == 0 ) {
			return (int)$date_value % $stepSize == 0;
		}

		$range = explode('-', $parts[0], 2);
		$offset = $range[0];
		$to = isset($range[1]) ? $range[1] : $date_value;

		// Ensure that the date value is within the range
		if ( $date_value < $offset || $date_value > $to ) {
			return false;
		}

		for ($i = $offset; $i <= $to; $i += $stepSize) {
			if ( $i == $date_value ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Increments/decrements given date for 1 unit based on field type
	 *
	 * @param int $field_type
	 * @param int $date
	 * @param bool $inverse
	 * @return int
	 * @access public
	 */
	public function increment($field_type, $date, $inverse = false)
	{
		$mapping = Array (
			kCronHelper::MINUTE => '1 minute',
			kCronHelper::HOUR => '1 hour',
			kCronHelper::DAY => '1 day',
			kCronHelper::MONTH => '1 month',
			kCronHelper::WEEKDAY => '1 day',
		);

		return $this->_resetTime($field_type, strtotime(($inverse ? '-' : '+') . $mapping[$field_type], $date), $inverse);
	}

	/**
	 * Resets time based on field type
	 *
	 * @param int $field_type
	 * @param int $date
	 * @param bool $inverse
	 * @return int
	 * @access public
	 */
	protected function _resetTime($field_type, $date, $inverse = false)
	{
		if ( $field_type == kCronHelper::MONTH || $field_type == kCronHelper::WEEKDAY || $field_type == kCronHelper::DAY ) {
			if ( $inverse ) {
				$date = strtotime(adodb_date('Y-m-d 23:59:59', $date));
				// set time 23:59:00
			}
			else {
				// set time 00:00:00
				$date = strtotime(adodb_date('Y-m-d 00:00:00', $date));
			}
		}
		elseif ( $field_type == kCronHelper::HOUR ) {
			if ( $inverse ) {
				// set time <current_hour>:59:00
				$date = strtotime(adodb_date('Y-m-d H:59:59', $date));
			}
			else {
				// set time <current_hour>:00:00
				$date = strtotime(adodb_date('Y-m-d H:00:00', $date));
			}
		}

		return $date;
	}
}