<?php

// define points in time for an event,
// also allows for custom events

	 $GLOBALS['custom_event'] = [];		// this is for custom events

class Schedule {
	
	const SUN = 0;
	const MON = 1;
	const TUE = 2;
	const WED = 3;
	const THU = 4;
	const FRI = 5;
	const SAT = 6;
	
	private $interval = FALSE;
	private $dow = [];				// what days of the week should a task be run
	private $weekly = FALSE;
	private $monthly = FALSE;
//	private $daily =  FALSE;		// daily is assumed
	private $hourly = FALSE;
	private $at = FALSE;
	private $tz = 'UTC';
	private $between = FALSE;
	private $between_not = FALSE;
	private $callback = FALSE;
	private $custom_event = [];

	public function timezone($x = 'UTC') {		// defaults to utc
		// evaluate the tz here, store a delta?
		$this->tz = $x;
	}
	
	// Run the task every x minutes
	//  unlike linux crontab, this is not based on the minute of the hour, but from 1-jan 1980
	// so "every 7 minutes" would be called about 205 times per day (60min * 24h / 7min)
	public function everyXMinutes() {
		if (func_num_args()) {
			$this->interval = func_get_arg(0);
			return $this;
		}
		return $this->interval;
	}

	public function monthly($day) {		// day = -1 for last day of month
		if ($this->monthly === FALSE) {
			$this->monthly = [];
		}
		$this->monthly[$day] = 1;		// then add specific time: at()
		ksort($this->monthly);
		return $this;
	}

	private function _timeToMinutes($hhmm) {		// must be 24hr format
		list($h, $m) = explode(':', $hhmm . ':');
		$h = intval($h, 10);
		if ($h < 0) $h = 0;
		if ($h > 23) $h = 23;
		$m = intval($m, 10);
		if ($m < 0) $m = 0;
		if ($m > 59) $m = 59;
		return $h * 60 + $m;
	}
	// specific times with the chosen day	=> monthly(15)->at('13:00', '13:13')->at('15:00')->at(['15:30', '15:31']) 
	// must be 24hr format:  hh:mm
	//(does not make sense when used with Between and NotBetween, nor does it make sense if everyXMinutes() is used )
	public function at() {
		$c = func_num_args();
		if ($c > 1) {
			$list = func_get_args();
		} elseif ($c == 1) {
			$list = func_get_arg(0);
			if (!is_array($list)) $list = [$list];
		} else {
			return $this;
		}
		foreach($list as &$item) {
			$item = $this->_timeToMinutes($item);
		}
		if ($this->at === FALSE) $this->at = [];
		$this->at = $this->at + $list;
		sort($this->at);
		return $this;
	}
	
	public function between($time1, $time2) {			// if $time1 > $time2 then assumes: not between($time2, $time1)
		$minutes1 = $this->_timeToMinutes($time1);
		$minutes2 = $this->_timeToMinutes($time2);
		if ($minutes1 > $minutes2) return $this->NotBetween($time2, $time1);
		$this->between = [$minutes1, $minutes2];
		return $this;
	}

	public function notbetween($time1, $time2) {			// if $time1 > $time2 then assumes: not between($time2, $time1)
		$minutes1 = $this->_timeToMinutes($time1);
		$minutes2 = $this->_timeToMinutes($time2);
		if ($minutes1 > $minutes2) return $this->Between($time2, $time1);
		$this->between_not = [$minutes1, $minutes2];
		return $this;
	}
	
	public function sundays() {
		$this->weekly = TRUE;
		$this->dow[self::SUN] = 1;
		return $this;
	}
	public function mondays() {
		$this->weekly = TRUE;
		$this->dow[self::MON] = 1;
		return $this;
	}
	public function tuesdays() {
		$this->weekly = TRUE;
		$this->dow[self::TUE] = 1;
		return $this;
	}
	public function wednesdays() {
		$this->weekly = TRUE;
		$this->dow[self::WED] = 1;
		return $this;
	}
	public function thursdays() {
		$this->weekly = TRUE;
		$this->dow[self::THU] = 1;
		return $this;
	}
	public function fridays() {
		$this->weekly = TRUE;
		$this->dow[self::FRI] = 1;
		return $this;
	}
	public function saturdays() {
		$this->weekly = TRUE;
		$this->dow[self::SAT] = 1;
		return $this;
	}
	public function weekdays() {
		$this->weekly = TRUE;
		$this->dow[self::MON] = 1;
		$this->dow[self::TUE] = 1;
		$this->dow[self::WED] = 1;
		$this->dow[self::THU] = 1;
		$this->dow[self::FRI] = 1;
		return $this;
	}
	public function hourly($minute = 0) {		// run hourly at :minute
		$this->hourly = $minute;
		return $this;
	}
	
	// Required field - specify the callback function
	public function Execute() {
		if (func_num_args()) {
            $this->callback = func_get_arg(0);
            return $this;
        }
        return $this->callback;
	}
    
    private function get_topmost_script() {
        $backtrace = debug_backtrace(
          defined('DEBUG_BACKTRACE_IGNORE_ARGS')
          ? DEBUG_BACKTRACE_IGNORE_ARGS
          : FALSE);
        $top_frame = array_pop($backtrace);
        return $top_frame['file'];
    }
    
    // return event identifier, based on $callback
    public function ID() {
        $salt = '';
		if (is_array($this->callback)) {
			$salt = get_class($this->callback[0]) . '->' . $this->callback[1];
		}
        if ($salt == '') $salt = $this->get_topmost_script();   // get the parent file 
        return sha1($salt);
    }

	// return start time of current event, or false if it is not now.
	public function EventTime($now) {
		// set timezone
		$current = $this->_timeToArray($now);
		$next_event = FALSE;
		if ($this->interval !== FALSE) {
			$next_event = $this->_nextInterval($current);
		} elseif ($this->hourly !== FALSE) {
			$next_event = $this->_getHourly($current);
		}
		if ($this->weekly) {
			$dow = $next_event['dow'];
			if (!isset($this->dow[$dow])) return FALSE;			// wrong day
		}
		if ($this->monthly !== FALSE) {
			$ok = FALSE;
			if (isset($this->monthly[$next_event['d']])) $ok = TRUE;
			if ($next_event['d'] == $next_event['dim'] && isset($this->monthly[-1])) $ok = TRUE;
			if (!$ok) return FALSE;	// not on any of the specific days of the month
		}
		
		$minutes = $next_event['h'] * 60 + $next_event['i'];
		// if Between range specified, and not in range then return FALSE;		"not at this time"
		if ($this->between !== FALSE) {
			if ($minutes < $this->between[0] || $minutes > $this->between[1]) return FALSE;
		}
		// if not Between range specified, and in range then return FALSE;
		if ($this->between_not !== FALSE) {
			if ($minutes >= $this->between[0] && $minutes <= $this->between[1]) return FALSE;
		}

		// specific times?
		if (count($this->at)) {
			if (!$this->_checkSpecificTimes($minutes)) return FALSE;
		}
		return $next_event === FALSE ? FALSE : $next_event['time'];
	}

	private function _getFirstMatchingDate($current) {
        $day = $current['d'];
        $limit = 12;        // don't look more than 12 months in advance
		if ($this->monthly !== FALSE) {
            $min = $day;
            do {
                $suitable_days = $this->monthly;
                $dim = $current['dim'];
                if (isset($suitable_days[-1])) {
                    unset($suitable_days[-1]);
                    $suitable_days[$dim] = 1;
                }
                // find the day of the month that on/after the current day
                $ok = FALSE;
                foreach($this->monthly as $day => $filler) {
                    if (($min !== FALSE && $min < $day) || ($day > $dim)) continue;
                    $ok = TRUE;
                    break;
                }
                // if we have a good DOM, make sure it is also a good DOW
                if ($this->weekly && $ok) {
					$tempdate = $current;
					$tempdate['d'] = $day;
					$this->_updateEventTime($tempdate);
					$dow = $tempdate['dow'];
					if (empty($this->dow[$dow])) $ok = FALSE;
                }
                if (!$ok) {     // move on to next month
                    $limit--;
                    if (!$limit) return FALSE;      // unable to find suitable day within the next 12 months
					$this->_nextMonth($current);
                }
                $min = FALSE;   // all dates are ok on future months
            } while (!$ok);
        } elseif ($this->weekly) {
			// at least one day of the week should be suitable
            for($i = 0; $i < 7; $i++) {
				$dow = $current['dow'];
				if (!empty($this->dow[$dow])) break;
				$this->_NextDay($current);
            }
			$day = $current['d'];
        }
        $dt = gmmktime(0, 0, 0, $current['m'], $day, $current['y']);
        return $dt;
	}
	
	// what time should the next event run?
	public function NextEvent($now) {
		$current = $this->_timeToArray($now);
		$next_event = FALSE;

		// use between and not between to get list of ranges within that day
		$ranges = $this->_between_ranges();

		// find the first time >= now()
		for($i = 0; $i < 2; $i++) {		// today or tomorrow
			if ($this->weekly || $this->monthly !== FALSE) {
				$next_event = $this->_getFirstMatchingDate($current);
				$tomorrow = strtotime('+ 1 day', $current['mid']);
				if ($next_event >= $tomorrow) {
					$current = $this->_timeToArray($next_event);		// we have moved on to a day in the future
				}
			} else {
				$next_event = gmmktime(0, 0, 0, $current['m'], $current['d'], $current['y']);
			}
			if ($this->at !== FALSE) {
				foreach($this->at as $marker) {
					$time = $next_event + $marker * 60;
					if ($time >= $now) return $time;
				}
		// if interval used, get next valid time
		// return first valid time in Between ranges
			} elseif ($this->interval !== FALSE) {
				$found = $this->_nextInterval_Range($current, $ranges);
				if ($found !== FALSE) return $found;
			} elseif ($this->hourly !== FALSE) {
				$found = $this->_getHourly_Range($current, $ranges);
				if ($found !== FALSE) return $found;
			}

			$this->_NextDay($current);
		}
		return FALSE;
	}
	
	private function _removeSlice(&$res, $slice) {
		foreach($res as $index => &$timeslot) {
			if ($slice[0] <= $timeslot[0] && $slice[1] >= $timeslot[1]) {				// time completely covers slice - remove it
				unset($res[$index]);
			} else if ($slice[0] >= $timeslot[0] && $slice[1] <= $timeslot[1]) {		// time completely within this slice - make a hole
				$res[] = [$slice[1], $timeslot[1]];
				$timeslot[1] = $slice[0];
				return;
			} elseif ($slice[0] >= $timeslot[0] && $slice[0] <= $timeslot[1]) {			// start time within this slice, trim end time
				$timeslot[1] = $slice[0];
			} elseif ($slice[1] >= $timeslot[0] && $slice[1] <= $timeslot[1]) {			// end time within this slice, trim start time
				$timeslot[0] = $slice[1];
			}
		}
	}
	
	// return a list of segments where time is suitable
	private function _between_ranges() {
		$result = [];
		if ($this->between === FALSE) {
			$result[] = [0, 60 * 24 - 1];
		} else {
			$result[] = $this->between;
		}
		if ($this->between_not !== FALSE) {
			$this->_removeSlice($result, $this->between_not);
		}
		return $result;
	}
	
	private function _checkSpecificTimes($minutes) {
		return in_array($minutes, $this->at);
	}
	
	private function _timeToArray($now) {
		$current_time = time(); // get time current time
		// adjust for Event timezone offset 
		list($dd,$dow,$mm,$hh,$ii,$ss,$dim,$yy) = explode(',', gmdate('j,w,n,G,i,s,t,Y', $current_time));
		$now -= intval($ss, 10);
		$node = [
			'time' => $now,
			'd' => intval($dd, 10),
			'dow' => $dow,		// day of week. 0 = Sunday
			'dim' => $dim,		// days in month
			'm' => $mm,
			'h' => $hh,
			'i' => intval($ii, 10),
			's' => intval($ss, 10),
			'y' => $yy,
		];
		$node['ofs'] = $node['h'] * 60 + $node['i'];	// minutes past midnight
		$node['mid'] = $now - 60 * $node['ofs'];		// midnight
		return $node;
	}
	
	private function _updateEventTime(&$event) {
		$event['time'] = mktime($event['h'], $event['i'], 0, $event['m'], $event['d'], $event['y']);
		list($event['dow'],$event['dim']) = explode(',', gmdate('w,t', $event['time']));
		$event['ofs'] = $event['h'] * 60 + $event['i'];			// minutes past midnight
		$event['mid'] = $event['time'] - 60 * $event['ofs'];	// midnight
	}

	private function _nextDay(&$event) {
		$event['d']++;
		$event['h'] = $event['i'] = 0;
		if ($event['d'] > $event['dim']) {
			$this->_nextMonth($event);
		} else {
			$this->_updateEventTime($event);
		}
	}
	
	private function _nextMonth(&$event) {
		$event['m']++;
		$event['d'] -= $event['dim'];
		if ($event['m'] > 12) {
			$event['m'] = 1;
			$event['y']++;
		}
		$this->_updateEventTime($event);
	}
	
	private function _normalize(&$event) {
		while($event['i'] > 59) {
			$event['h']++;
			$event['i'] -= 60;
		}
		while($event['h'] > 23) {
			$event['d']++;
			$event['h'] -= 24;
		}
		while($event['d'] > $event['dim']) {
			$this->_nextMonth($event);
		}
		while($event['m'] > 12) {		//bug: won't handle Feb and leap years
			$event['m'] -= 12;
			$event['y']++;
		}
	}

	private function _nextInterval($next_event) {
		$since = intval($next_event['time'] / 60) % $this->interval;	// how many minutes since last event?
		if (!$since) return $next_event;	// now is good :)
		$delta_minutes = $this->interval - $since;		// minutes to next interval
		$next_event['i'] += $delta_minutes;
		$this->_normalize($next_event);
		$this->_updateEventTime($next_event);
		return $next_event;
	}
	
	// determine the next Interval event that falls within $range
	private function _nextInterval_Range($next_event, $range) {
		foreach($range as $timeslot) {
			$first = $this->_getFirstIntervalInTimeSlot($next_event, $timeslot);
			if ($first !== FALSE) return $first;
		}
		return FALSE;
	}

	private function _getHourly($next_event) {
		$current_minute = $next_event['i'];
		$next_event['i'] = $this->hourly;
		
		if ($current_minute > $this->hourly) {
			$next_event['h']++;
			$this->_normalize($next_event);
		}
		$this->_updateEventTime($next_event);
		return $next_event;
	}

	private function _getHourly_range($next_event, $range) {
		foreach($range as $timeslot) {
			$first = $this->_getFirstHourlyInTimeSlot($next_event, $timeslot);
			if ($first !== FALSE) return $first;
		}
		return FALSE;
	}
	
	// get the time of the next event, if it falls within the timeslot
	private function _getFirstIntervalInTimeSlot($next_event, $timeslot) {
		$end = $next_event['mid'] + $timeslot[1] * 60;
		if ($next_event['time'] > $end) return FALSE;		// this timeslot has expired - ignore it
		$start = $next_event['mid'] + $timeslot[0] * 60;
		if ($next_event['time'] > $start) $start = $next_event['time'];
		
		$since = intval($start / 60) % $this->interval;
		if (!$since) return $start;				// now is good
		$delta_minutes = $this->interval - $since;		// minutes to next interval
		$event_time = $start + $delta_minutes * 60;
		return $event_time > $end ? FALSE : $event_time;			// next interval does not occur in this timeslot
	}
	
	// get the time of the next event, if it falls within the timeslot
	private function _getFirstHourlyInTimeSlot($next_event, $timeslot) {
		$end = $next_event['mid'] + $timeslot[1] * 60;
		if ($next_event['time'] > $end) return FALSE;		// this timeslot has expired - ignore it
		$start = $next_event['mid'] + $timeslot[0] * 60;
			
		while ($next_event['time'] < $start) {
			$next_event['time']+= 3600;
			$next_event['h']++;
		}
		$event_time = $next_event['h'] * 60 + $this->hourly;
		$event_time = $next_event['mid'] + $event_time * 60;
		if ($next_event['i'] >= $this->hourly) {	// passed desired minute, so try next hour
			$event_time += 3600;
		}
		return $event_time > $end ? FALSE : $event_time;			// next interval does not occur in this timeslot
	}
	
	public function __toString() {
		if (!is_callable($this->callback)) return '(inactive)';
		$next_event = '';
		if (is_array($this->callback)) {
			$classname = get_class($this->callback[0]) . '->' . $this->callback[1];
			$next_event .= $classname;
		} else {
			$next_event .= "(unknown callback)";
		}
        $next = $this->nextEvent(time());
        $next = $next === FALSE ? 'Unknown' : gmdate('D, Y-m-d H:i:s \(\U\T\C\)', $next);
		$next_event .= ', next event: ' . $next;
		return $next_event;
	}
	
	public function __call($name, $arguments) {
		if (count($arguments)) {		// set value
			if (is_a($arguments[0], 'moduleMain')) {
				$settings = $arguments[0]->settings;
			} else {
				$settings = [];
			}
			$settingname = $arguments[1];
			$this->custom_event[$name] = [
				'args'	=> $arguments,
				'name'	=> $settingname,
				'value'	=> $settings[$settingname] ?? '',
			];
			return $this;
		}
		return $this->custom_event[$name]['value'] ?? NULL;					// get value
	}

		// this is done before the Register
	public function performCustomEventCallback() {
//		error_log('perform: ' . json_encode(array_keys($GLOBALS['custom_event'])));
		foreach($this->custom_event as $name => $details) {
			if (isset($GLOBALS['custom_event'][$name])) {
				$node = $GLOBALS['custom_event'][$name];
				call_user_func($node['callback'], $this, $details['value'], $details['args']);
			}
		}
	}
	
	// use $callback to set up a custom event named $eventname
	/*
		usage
		    	$event = new \Schedule();
				$event	->{$eventname}(params) 			// replace {$eventname} with the action event name 
			
	*/
	static 
	public function RegisterCustomEvent($eventname, $callback) {
		$GLOBALS['custom_event'][$eventname] = [
			'callback' => $callback,
		];
//		error_log('register: ' . json_encode(array_keys($GLOBALS['custom_event'])));
	}
}


/*
->cron('* * * * *'); 	Run the task on a custom Cron schedule

->hourly(); 	Run the task every hour
	>between('8:00', '17:00');
->hourlyAt(17); 	Run the task every hour at 17 mins past the hour
//->daily(); 	Run the task every day at midnight
//->dailyAt('13:00'); 	Run the task every day at 13:00
->twiceDaily(1, 13); 	Run the task daily at 1:00 & 13:00
//->weekly(); 	Run the task every week (not used.) 
	->between($start, $end); 	Limit the task to run between start and end times
	->when(Closure); 	Limit the task based on a truth test	
	->unlessBetween('23:00', '4:00');
	 ->between('7:00', '22:00');
	 $schedule->command('emails:send')->daily()->when(function () {
		return true;
	});
	->withoutOverlapping();
	  ->onOneServer();
	  ->sendOutputTo($filePath);  >appendOutputTo($filePath); >emailOutputTo('foo@example.com');
	    ->before(function () {
             // Task is about to start...
         })
         ->after(function () {
             // Task is complete...
         });
		 ->pingBefore($url)
         ->thenPing($url);
->monthly(); 	Run the task every month
->monthlyOn(4, '15:00'); 	Run the task every month on the 4th at 15:00
->quarterly(); 	Run the task every quarter
->yearly(); 	Run the task every year
->timezone('America/New_York'); 	Set the timezone
*/