<?php namespace ProcessWire;

/**
 * ProcessWire LazyCron Module
 * ===========================
 *
 * Provides hooks that are automatically executed at various intervals. 
 * It is called 'lazy' because it's triggered by a pageview, so it's accuracy 
 * executing at specific times will depend upon how may pageviews your site gets. 
 * So when a specified time is triggered, it's guaranteed to have been that length
 * of time OR longer. This is fine for most cases. 
 * But here's how you make it NOT lazy:
 * 
 * Setup a real CRON job to pull a page from your site once per minute. 
 * Here is an example of a command that you could schedule to execute once per
 * minute:
 * 
 * wget --quiet --no-cache -O - http://www.your-site.com > /dev/null
 * 
 * 
 * USAGE IN YOUR MODULES: 
 * ----------------------
 *
 * // In your own module or template, add the function you want executed:
 * public function myFunc(HookEvent $e) { echo "30 Minutes have passed!"; }
 * 
 * // Then add the hook to it in your module's init() function:
 * $this->addHook('LazyCron::every30Minutes', $this, 'myFunc'); 
 *
 * 
 * PROCEDURAL USAGE (i.e. in templates): 
 * -------------------------------------
 * 
 * // create your hook function
 * function myHook(HookEvent $e) { echo "30 Minutes have passed!"; }
 * 
 * // add a hook to it:
 * $wire->addHook('LazyCron::every30Minutes', null, 'myFunc'); 
 *
 * 
 * FUNCTIONS YOU CAN HOOK:
 * -----------------------
 *
 * every30Seconds  
 * everyMinute
 * every2Minutes
 * every3Minutes
 * every4Minutes
 * every5Minutes
 * every10Minutes
 * every15Minutes
 * every30Minutes
 * every45Minutes
 * everyHour
 * every2Hours
 * every4Hours
 * every6Hours
 * every12Hours
 * everyDay
 * every2Days
 * every4Days
 * everyWeek
 * every2Weeks
 * every4Weeks
 * 
 * 
 * ProcessWire 3.x, Copyright 2024 by Ryan Cramer
 * https://processwire.com
 *
 *
 */

class LazyCron extends WireData implements Module {

	public static function getModuleInfo() {
		return array(
			'title' => 'Lazy Cron', 
			'version' => 104, 
			'summary' => 
				"Provides hooks that are automatically executed at various intervals. " . 
				"It is called 'lazy' because it's triggered by a pageview, so the interval " . 
				"is guaranteed to be at least the time requested, rather than exactly the " . 
				"time requested. This is fine for most cases, but you can make it not lazy " . 
				"by connecting this to a real CRON job. See the module file for details. ", 
			'href' => 'https://processwire.com/api/modules/lazy-cron/',
			'permanent' => false, 
			'singular' => true, 
			'autoload' => true, 
		);
	}

	/**
	 * Hookable time functions that we are allowing indexed by number of seconds. 
	 *
	 */
	protected $timeFuncs = array(
		30 => 'every30Seconds',
		60 => 'everyMinute',	
		120 => 'every2Minutes',
		180 => 'every3Minutes',
		240 => 'every4Minutes',
		300 => 'every5Minutes', 
		600 => 'every10Minutes',
		900 => 'every15Minutes',
		1800 => 'every30Minutes',
		2700 => 'every45Minutes', 
		3600 => 'everyHour',
		7200 => 'every2Hours',
		14400 => 'every4Hours',
		21600 => 'every6Hours',
		43200 => 'every12Hours',
		86400 => 'everyDay',
		172800 => 'every2Days',	
		345600 => 'every4Days',
		604800 => 'everyWeek',
		1209600 => 'every2Weeks',
		2419200 => 'every4Weeks',
	);

	/**
	 * @var string
	 * 
	 */
	protected $lockfile = '';

	/**
	 * Initialize the hooks
	 *
	 */
	public function init() {
		$this->addHookAfter('ProcessPageView::finished', $this, 'afterPageView'); 
	}

	/**
	 * Function triggered after every page view. 
 	 *
	 * This is intentionally scheduled after the page has been delivered so 
	 * that the cron jobs don't slow down the pageview. 
	 * 
	 * @param HookEvent $e
	 *
	 */
	public function afterPageView(HookEvent $e) {

		/** @var ProcessPageView $process */
		$process = $e->object;
		// don't execute cron now if this is anything other than a normal response
		$responseType = $process->getResponseType();
		if($responseType != ProcessPageView::responseTypeNormal) return;

		$files = $this->wire()->files;
		$cachePath = $this->wire()->config->paths->cache;
		$time = time();
		$filename = $cachePath . 'LazyCron.cache'; 
		$this->lockfile = $cachePath . 'LazyCronLock.cache'; 
		$times = array();
		$writeFile = false;

		if(is_file($this->lockfile)) {
			// other LazyCron process potentially running
			if(filemtime($this->lockfile) < (time() - 3600)) {
				// expired lock file, some fatal error must have occurred during last LazyCron run
				$this->removeLockfile();
			} else {
				// skip running this time as an active lock file exists
				return;
			}
		}

		if(!$files->filePutContents($this->lockfile, (string) time(), LOCK_EX)) {
			// $this->error("Unable to write lock file: $this->lockfile", Notice::logOnly); 
			return;
		}
		
		if(is_file($filename)) {
			$filedata = file($filename, FILE_IGNORE_NEW_LINES); 

			// file is probably locked, so skip it this time
			if($filedata === false) {
				$this->removeLockfile();
				return; 
			}
		} else {
			// file does not exist
			$filedata = false;
		}

		$shutdown = $this->wire()->shutdown;
		if($shutdown) $shutdown->addHook('fatalError', $this, 'hookFatalError');

		if($filedata) {
			$n = 0;
			foreach($this->timeFuncs as $seconds => $func) {
				$lasttime = (int) (isset($filedata[$n]) ? $filedata[$n] : $time);
				$elapsedTime = $time - $lasttime;
				if(empty($filedata[$n]) || $elapsedTime >= $seconds) {
					try {
						$this->$func($elapsedTime);
					} catch(\Exception $e) {
						$this->error($e->getMessage(), Notice::logOnly); 
					}
					$lasttime = $time;
					$writeFile = true;
				}
				$times[$seconds] = $lasttime;
				$n++;	
			}
		} else {
			// file does not exist
			$writeFile = true;
			foreach($this->timeFuncs as $seconds => $func) {
				try {
					$this->$func(0);
				} catch(\Exception $e) {
					$this->error($e->getMessage(), Notice::logOnly); 
				}
				$times[$seconds] = $time;
			}
		}

		if($writeFile) { 
			$files->filePutContents($filename, implode("\n", $times), LOCK_EX);
		}

		$this->removeLockfile();
	}

	/**
	 * Remove lock file
	 * 
	 */
	protected function removeLockfile() {
		if(!$this->lockfile || !is_readable($this->lockfile)) return;
		$files = $this->wire()->files;
		if($files) {
			$files->unlink($this->lockfile, false, false);
		} else {
			unlink($this->lockfile); // might be impossible to reach 
		}
	}

	/**
	 * Hook to WireShutdown::fatalError
	 * 
	 * @param HookEvent $e
	 * 
	 */
	public function hookFatalError(HookEvent $e) {
		$this->removeLockfile();
		$e->removeHook(null); // remove self
	}

	/**
	 * Get hookable time functions that are allowed, indexed by number of seconds they use
	 * 
	 * @return string[]
	 * @since 3.0.236
	 * 
	 */
	public function getTimeFuncs() {
		return $this->timeFuncs; 
	}

	/**
	 * Get name of time function to hook for given number of seconds
	 * 
	 * Returns the time function name that most closely matches given number of seconds. 
	 * 
	 * @param int $seconds Number of seconds
	 * @return string Name of time function to hook
	 * @since 3.0.236
	 * 
	 */
	public function getTimeFuncName($seconds) {
		$nLast = 0;
		$timeFunc = '';
		$lastFuncName = '';
		foreach($this->timeFuncs as $n => $funcName) {
			$timeFunc = $funcName;
			$diff = ($n - $nLast) / 2;
			if($n - $diff > $seconds) {
				$timeFunc = $lastFuncName;
				break;
			} else if($n >= $seconds) {
				break;
			}
			$nLast = $n;
			$lastFuncName = $funcName;
		}
		return $timeFunc;
	}

	/**
	 * Get number of seconds for given time function name or 0 if not found
	 * 
	 * 
	 * @param string $timeFuncName This is case sensitive, so you must use `everyDay`, `every30Minutes`, etc.
	 * @return int Number of seconds associated with time function name. 
	 * @since 3.0.236
	 * 
	 * 
	 */
	public function getTimeFuncSeconds($timeFuncName) {
		return (int) array_search($timeFuncName, $this->timeFuncs);
	}

	/**
	 * One or more of the following functions is called if the given interval has passed.
	 *
	 * You can hook into any of these functions and your hook will be called at the given interval.
	 *
 	 * @param int $seconds The number of seconds that have actually elapsed. Most likely not useful, but provided just in case.
	 *
	 */

	public function ___every30Seconds($seconds) { }
	public function ___everyMinute($seconds) { }
	public function ___every2Minutes($seconds) { }
	public function ___every3Minutes($seconds) { }
	public function ___every4Minutes($seconds) { }
	public function ___every5Minutes($seconds) { }
	public function ___every10Minutes($seconds) { }
	public function ___every15Minutes($seconds) { }
	public function ___every30Minutes($seconds) { }
	public function ___every45Minutes($seconds) { }
	public function ___everyHour($seconds) { }
	public function ___every2Hours($seconds) { }
	public function ___every4Hours($seconds) { }
	public function ___every6Hours($seconds) { }
	public function ___every12Hours($seconds) { }
	public function ___everyDay($seconds) { }
	public function ___every2Days($seconds) { }
	public function ___every4Days($seconds) { }
	public function ___everyWeek($seconds) { }
	public function ___every2Weeks($seconds) { }
	public function ___every4Weeks($seconds) { }
	
	/**
	 * Uninstall
	 * 
	 */
	public function ___uninstall() {
		$files = $this->wire()->files;
		$cachePath = $this->wire()->config->paths->cache;
		$file = $cachePath . 'LazyCron.cache';
		if(is_file($file)) $files->unlink($file, true, false);
		$file = $cachePath . 'LazyCronLock.cache';
		if(is_file($file)) $files->unlink($file, true, false);
	}

}
