<?php

/**
 * Model for cron behaviors.
 *
 * @package XenForo_Cron
 */
class XenForo_Model_Cron extends XenForo_Model
{
	/**
	 * Gets the specified cron entry.
	 *
	 * @param string $id
	 *
	 * @return array|false
	 */
	public function getCronEntryById($id)
	{
		return $this->_getDb()->fetchRow('
			SELECT *
			FROM xf_cron_entry
			WHERE entry_id = ?
		', $id);
	}

	/**
	 * Prepares a list of cron entries for display.
	 *
	 * @param array $entries
	 *
	 * @return array
	 */
	public function prepareCronEntries(array $entries)
	{
		foreach ($entries AS &$entry)
		{
			$entry = $this->prepareCronEntry($entry);
		}

		return $entries;
	}

	/**
	 * Prepares the given cron entry for display, by doing processing beyond
	 * the DB, preparing the title phrase, etc.
	 *
	 * @param array $entry
	 *
	 * @return array
	 */
	public function prepareCronEntry(array $entry)
	{
		$entry['runRules'] = XenForo_Helper_Php::safeUnserialize($entry['run_rules']);
		$entry['title'] = new XenForo_Phrase($this->getCronEntryPhraseName($entry['entry_id']));

		return $entry;
	}

	/**
	 * Gets the default, prepared cron entry for use on the insert entry form.
	 *
	 * @return array
	 */
	public function getDefaultCronEntry()
	{
		return array(
			'entry_id' => '',
			'cron_class' => '',
			'cron_method' => '',
			'run_rules' => '',
			'active' => 1,
			'next_run' => 0,
			'addon_id' => null, // must fail isset

			'runRules' => array(
				'minutes' => array(0),
				'hours' => array(0),
				'day_type' => 'dom',
				'dom' => array(-1)
			),
			'title' => ''
		);
	}

	/**
	 * Gets all cron entries, ordered by their next run time.
	 *
	 * @return array Format: [entry id] => info
	 */
	public function getAllCronEntries()
	{
		return $this->fetchAllKeyed('
			SELECT entry.*, IF(addon.addon_id IS NULL, 1, addon.active) AS addon_active
			FROM xf_cron_entry AS entry
			LEFT JOIN xf_addon AS addon ON (entry.addon_id = addon.addon_id)
			ORDER BY entry.next_run
		', 'entry_id');
	}

	/**
	 * Gets all cron entries that belong to the specified add-on,
	 * ordered by their entry IDs.
	 *
	 * @param string $addOnId
	 *
	 * @return array Format: [entry id] => info
	 */
	public function getCronEntriesByAddOnId($addOnId)
	{
		return $this->fetchAllKeyed('
			SELECT *
			FROM xf_cron_entry
			WHERE addon_id = ?
			ORDER BY entry_id
		', 'entry_id', $addOnId);
	}

	/**
	 * Gets the named cron entries.
	 *
	 * @param array $ids List of cron entries by IDs
	 *
	 * @return array Format: [entry id] => info
	 */
	public function getCronEntriesByIds(array $ids)
	{
		if (!$ids)
		{
			return array();
		}

		return $this->fetchAllKeyed('
			SELECT *
			FROM xf_cron_entry
			WHERE entry_id IN (' . $this->_getDb()->quote($ids) . ')
			ORDER BY entry_id
		', 'entry_id');
	}

	/**
	 * Gets all cron entries that need to be run.
	 *
	 * @param integer|null $currentTime Current timestamp, null to use current time from application
	 *
	 * @return array Format: [entry id] => info
	 */
	public function getCronEntriesToRun($currentTime = null)
	{
		$currentTime = ($currentTime === null ? XenForo_Application::$time : $currentTime);

		return $this->fetchAllKeyed('
			SELECT entry.*
			FROM xf_cron_entry AS entry
			LEFT JOIN xf_addon AS addon ON (entry.addon_id = addon.addon_id)
			WHERE entry.active = 1
				AND entry.next_run < ?
				AND (addon.addon_id IS NULL OR addon.active = 1)
			ORDER BY entry.next_run
		', 'entry_id', $currentTime);
	}

	/**
	 * Atomically update the next run time for a cron entry. This allows you
	 * to determine whehter a cron entry still needs to be run.
	 *
	 * @param array $entry Cron entry info
	 *
	 * @return boolean True if updated (thus safe to run), false otherwise
	 */
	public function updateCronRunTimeAtomic(array $entry)
	{
		$runRules = XenForo_Helper_Php::safeUnserialize($entry['run_rules']);
		$nextRun = $this->calculateNextRunTime($runRules);

		$updateResult = $this->_getDb()->query('
			UPDATE xf_cron_entry
			SET next_run = ?
			WHERE entry_id = ?
				AND next_run = ?
		', array($nextRun, $entry['entry_id'], $entry['next_run']));

		if ($updateResult->rowCount())
		{
			return true;
		}
		else
		{
			return false;
		}
	}

	/**
	 * Calculate the next run time for an entry using the given rules. Rules expected in keys:
	 *    minutes, hours, dow, dom (all arrays) and day_type (string: dow or dom)
	 * Array rules are in format: -1 means "any", any other value means on those specific
	 * occurances. DoW runs 0 (Sunday) to 6 (Saturday).
	 *
	 * @param array $runRules Run rules. See above for format.
	 * @param integer|null $currentTime Current timestamp; null to use current time from application
	 *
	 * @return integer Next run timestamp
	 */
	public function calculateNextRunTime(array $runRules, $currentTime = null)
	{
		$currentTime = ($currentTime === null ? XenForo_Application::$time : $currentTime);

		$nextRun = new DateTime('@' . $currentTime);
		$nextRun->modify('+1 minute');

		if (empty($runRules['minutes']))
		{
			$runRules['minutes'] = array(-1);
		}
		$this->_modifyRunTimeMinutes($runRules['minutes'], $nextRun);

		if (empty($runRules['hours']))
		{
			$runRules['hours'] = array(-1);
		}
		$this->_modifyRunTimeHours($runRules['hours'], $nextRun);

		if (!empty($runRules['day_type']))
		{
			if ($runRules['day_type'] == 'dow')
			{
				if (empty($runRules['dow']))
				{
					$runRules['dow'] = array(-1);
				}
				$this->_modifyRunTimeDayOfWeek($runRules['dow'], $nextRun);
			}
			else
			{
				if (empty($runRules['dom']))
				{
					$runRules['dom'] = array(-1);
				}
				$this->_modifyRunTimeDayOfMonth($runRules['dom'], $nextRun);
			}
		}

		return intval($nextRun->format('U'));
	}

	/**
	 * Modifies the next run time based on the minute rules.
	 *
	 * @param array $minuteRules Rules about what minutes are valid (-1, or any number of values 0-59)
	 * @param DateTime $nextRun Date calculation object. This will be modified.
	 */
	protected function _modifyRunTimeMinutes(array $minuteRules, DateTime &$nextRun)
	{
		$currentMinute = $nextRun->format('i');
		$this->_modifyRunTimeUnits($minuteRules, $nextRun, $currentMinute, 'minute', 'hour');
	}

	/**
	 * Modifies the next run time based on the hour rules.
	 *
	 * @param array $hourRules Rules about what hours are valid (-1, or any number of values 0-23)
	 * @param DateTime $nextRun Date calculation object. This will be modified.
	 */
	protected function _modifyRunTimeHours(array $hourRules, DateTime &$nextRun)
	{
		$currentHour = $nextRun->format('G');
		$this->_modifyRunTimeUnits($hourRules, $nextRun, $currentHour, 'hour', 'day');
	}

	/**
	 * Modifies the next run time based on the day of month rules. Note that if
	 * the required DoM doesn't exist (eg, Feb 30), it will be rolled over as if
	 * it did (eg, to Mar 2).
	 *
	 * @param array $hourRules Rules about what days are valid (-1, or any number of values 0-31)
	 * @param DateTime $nextRun Date calculation object. This will be modified.
	 */
	protected function _modifyRunTimeDayOfMonth(array $dayRules, DateTime &$nextRun)
	{
		$currentDay = $nextRun->format('j');
		$this->_modifyRunTimeUnits($dayRules, $nextRun, $currentDay, 'day', 'month');
	}

	/**
	 * Modifies the next run time based on the day of week rules.
	 *
	 * @param array $hourRules Rules about what days are valid (-1, or any number of values 0-6 [sunday to saturday])
	 * @param DateTime $nextRun Date calculation object. This will be modified.
	 */
	protected function _modifyRunTimeDayOfWeek(array $dayRules, DateTime &$nextRun)
	{
		$currentDay = $nextRun->format('w'); // 0 = sunday, 6 = saturday
		$this->_modifyRunTimeUnits($dayRules, $nextRun, $currentDay, 'day', 'week');
	}

	/**
	 * General purpose run time calculator for a set of rules.
	 *
	 * @param array $unitRules List of rules for unit. Array of ints, values -1 to unit-defined max.
	 * @param DateTime $nextRun Date calculation object. This will be modified.
	 * @param integer $currentUnitValue The current value for the specified unit type
	 * @param string $unitName Name of the current unit (eg, minute, hour, day, etc)
	 * @param string $rolloverUnitName Name of the unit to use when rolling over; one unit bigger (eg, minutes to hours)
	 */
	protected function _modifyRunTimeUnits(array $unitRules, DateTime &$nextRun, $currentUnitValue, $unitName, $rolloverUnitName)
	{
		if (sizeof($unitRules) && reset($unitRules) == -1)
		{
			// correct already
			return;
		}

		$currentUnitValue = intval($currentUnitValue);
		$rollover = null;

		sort($unitRules, SORT_NUMERIC);
		foreach ($unitRules AS $unitValue)
		{
			if ($unitValue == -1 || $unitValue == $currentUnitValue)
			{
				// already in correct position
				$rollover = null;
				break;
			}
			else if ($unitValue > $currentUnitValue)
			{
				// found unit later in date, adjust to time
				$nextRun->modify('+ ' . ($unitValue - $currentUnitValue) . " $unitName");
				$rollover = null;
				break;
			}
			else if ($rollover == null)
			{
				// found unit earlier in the date; use smallest value
				$rollover = $unitValue;
			}
		}

		if ($rollover !== null)
		{
			$nextRun->modify(($rollover - $currentUnitValue) . " $unitName");
			$nextRun->modify("+ 1 $rolloverUnitName");
		}
	}

	/**
	 * Runs the given entry if possible.
	 *
	 * @param array $entry Info about cron entry
	 */
	public function runEntry(array $entry)
	{
		if (XenForo_Application::autoload($entry['cron_class']) && method_exists($entry['cron_class'], $entry['cron_method']))
		{
			call_user_func(
				array($entry['cron_class'], $entry['cron_method']),
				$entry
			);
		}
	}

	/**
	 * Gets the minimum next run time stamp (ie, time next entry is due to run).
	 * If no entries are runnable, returns 0x7FFFFFFF (basically never run an entry).
	 *
	 * @return integer
	 */
	public function getMinimumNextRunTime()
	{
		$nextRunTime = $this->_getDb()->fetchOne('
			SELECT MIN(entry.next_run)
			FROM xf_cron_entry AS entry
			LEFT JOIN xf_addon AS addon ON (entry.addon_id = addon.addon_id)
			WHERE entry.active = 1
				AND (addon.addon_id IS NULL OR addon.active = 1)
		');
		if ($nextRunTime)
		{
			return $nextRunTime;
		}
		else
		{
			// no crons? This shouldn't happen so it might be a mistake - check again in 30 minutes
			return XenForo_Application::$time + 30 * 60;
		}
	}

	/**
	 * Updates the entry for the minimum next run time.
	 * Cron calls are not needed until that point.
	 *
	 * @return integer Minimum next run time
	 */
	public function updateMinimumNextRunTime()
	{
		$minimumRunTime = intval($this->getMinimumNextRunTime());

		if ($minimumRunTime)
		{
			XenForo_Application::defer('Cron', array(), 'cron', false, $minimumRunTime);
		}

		return $minimumRunTime;
	}

	/**
	 * Retriggers the cron system if it doesn't appear to be active.
	 *
	 * @return bool
	 */
	public function retriggerCron()
	{
		$deferred = $this->_getDb()->fetchRow("
			SELECT *
			FROM xf_deferred
			WHERE unique_key = 'cron'
		");
		if ($deferred)
		{
			return false;
		}

		$this->updateMinimumNextRunTime();
		return true;
	}

	/**
	 * Gets the phrase name for a cron entry.
	 *
	 * @param string $entryId
	 *
	 * @return string
	 */
	public function getCronEntryPhraseName($entryId)
	{
		return 'cron_entry_' . $entryId;
	}

	/**
	 * Gets a cron entry's master title phrase text.
	 *
	 * @param string $entryId
	 *
	 * @return string
	 */
	public function getCronEntryMasterTitlePhraseValue($entryId)
	{
		$phraseName = $this->getCronEntryPhraseName($entryId);
		return $this->_getPhraseModel()->getMasterPhraseValue($phraseName);
	}

	/**
	 * Gets the file name for the development output.
	 *
	 * @return string
	 */
	public function getCronDevelopmentFileName()
	{
		$config = XenForo_Application::get('config');
		if (!$config->debug || !$config->development->directory)
		{
			return '';
		}

		return XenForo_Application::getInstance()->getRootDir()
			. '/' . $config->development->directory . '/file_output/cron.xml';
	}

	/**
	 * Gets the DOM document that represents the cron development file.
	 * This must be turned into XML (or HTML) by the caller.
	 *
	 * @return DOMDocument
	 */
	public function getCronDevelopmentXml()
	{
		$document = new DOMDocument('1.0', 'utf-8');
		$document->formatOutput = true;
		$rootNode = $document->createElement('cron');
		$document->appendChild($rootNode);

		$this->appendCronEntriesAddOnXml($rootNode, 'XenForo');

		return $document;
	}

	/**
	 * Appends the add-on cron entry XML to a given DOM element.
	 *
	 * @param DOMElement $rootNode Node to append all prefix elements to
	 * @param string $addOnId Add-on ID to be exported
	 */
	public function appendCronEntriesAddOnXml(DOMElement $rootNode, $addOnId)
	{
		$entries = $this->getCronEntriesByAddOnId($addOnId);

		$document = $rootNode->ownerDocument;

		foreach ($entries AS $entry)
		{
			$runRules = XenForo_Helper_Php::safeUnserialize($entry['run_rules']);

			$entryNode = $document->createElement('entry');
			$entryNode->setAttribute('entry_id', $entry['entry_id']);
			$entryNode->setAttribute('cron_class', $entry['cron_class']);
			$entryNode->setAttribute('cron_method', $entry['cron_method']);
			$entryNode->setAttribute('active', $entry['active']);
			$entryNode->appendChild($document->createCDATASection(json_encode($runRules)));

			$rootNode->appendChild($entryNode);
		}
	}

	/**
	 * Deletes the cron entries that belong to the specified add-on.
	 *
	 * @param string $addOnId
	 */
	public function deleteCronEntriesForAddOn($addOnId)
	{
		$db = $this->_getDb();
		$db->delete('xf_cron_entry', 'addon_id = ' . $db->quote($addOnId));
	}

	/**
	 * Imports prefixes from the development XML format. This will overwrite all prefixes.
	 *
	 * @param string $fileName
	 */
	public function importCronDevelopmentXml($fileName)
	{
		$document = XenForo_Helper_DevelopmentXml::scanFile($fileName);
		$this->importCronEntriesAddOnXml($document, 'XenForo');
	}

	/**
	 * Imports the cron entries for an add-on.
	 *
	 * @param SimpleXMLElement $xml XML element pointing to the root of the event data
	 * @param string $addOnId Add-on to import for
	 */
	public function importCronEntriesAddOnXml(SimpleXMLElement $xml, $addOnId)
	{
		$db = $this->_getDb();

		$addonEntries = $this->getCronEntriesByAddOnId($addOnId);

		XenForo_Db::beginTransaction($db);
		$this->deleteCronEntriesForAddOn($addOnId);

		$xmlEntries = XenForo_Helper_DevelopmentXml::fixPhpBug50670($xml->entry);

		$entryIds = array();
		foreach ($xmlEntries AS $entry)
		{
			$entryIds[] = (string)$entry['entry_id'];
		}

		$entries = $this->getCronEntriesByIds($entryIds);

		foreach ($xmlEntries AS $entry)
		{
			$entryId = (string)$entry['entry_id'];

			$dw = XenForo_DataWriter::create('XenForo_DataWriter_CronEntry');
			if (isset($entries[$entryId]))
			{
				$dw->setExistingData($entries[$entryId]);
			}

			if (isset($addonEntries[$entryId]))
			{
				$active = $addonEntries[$entryId]['active'];
			}
			else
			{
				$active = (string)$entry['active'];
			}

			$dw->setOption(XenForo_DataWriter_CronEntry::OPTION_REBUILD_CACHE, false);
			$dw->bulkSet(array(
				'entry_id' => $entryId,
				'cron_class' => (string)$entry['cron_class'],
				'cron_method' => (string)$entry['cron_method'],
				'active' => $active,
				'run_rules' => json_decode((string)$entry, true),
				'addon_id' => $addOnId
			));
			$dw->save();
		}

		$this->updateMinimumNextRunTime();

		XenForo_Db::commit($db);
	}

	/**
	 * @return XenForo_Model_Phrase
	 */
	protected function _getPhraseModel()
	{
		return $this->getModelFromCache('XenForo_Model_Phrase');
	}
}