<?php

/**
 * Licensed under the MIT license.
 *
 * For the full copyright and license information, please view the LICENSE file.
 *
 * @author Rémi Lanvin <remi@cloudconnected.fr>
 * @link https://github.com/rlanvin/php-rrule
 */

namespace RRule;

/**
 * Recurrence set
 */
class RSet implements RRuleInterface
{
	/**
	 * @var array List of RDATE (single dates)
	 */
	protected $rdates = array();

	/**
	 * @var array List of RRULE
	 */
	protected $rrules = array();

	/**
	 * @var array List of EXDATE (single dates to be excluded)
	 */
	protected $exdates = array();

	/**
	 * @var array List of EXRULES (single rules to be excluded)
	 */
	protected $exrules = array();

	// cache variable

	/**
	 * @var int|null Cache for the total number of occurrences
	 */
	protected $total = null;

	/**
	 * @var int|null Cache for the finite status of the RSet
	 */
	protected $infinite = null;

	/**
	 * @var array Cache for all the occurrences
	 */
	protected $cache = array();

	/**
	 * Constructor
	 *
	 * @param string $string a RFC compliant text block
	 */
	public function __construct($string = null, $default_dtstart = null)
	{
		if ( $string && is_string($string) ) {
			$string = trim($string);
			$rrules = array();
			$exrules = array();
			$rdates = array();
			$exdates = array();
			$dtstart = null;

			// parse
			$lines = explode("\n", $string);
			foreach ( $lines as $line ) {
				$line = trim($line);

				if ( strpos($line,':') === false ) {
					throw new \InvalidArgumentException('Failed to parse RFC string, line is not starting with a property name followed by ":"');
				}

				list($property_name,$property_value) = explode(':',$line);
				$tmp = explode(";",$property_name);
				$property_name = $tmp[0];
				switch ( strtoupper($property_name) ) {
					case 'DTSTART':
						if ( $default_dtstart || $dtstart !== null ) {
							throw new \InvalidArgumentException('Failed to parse RFC string, multiple DTSTART found');
						}
						$dtstart = $line;
					break;
					case 'RRULE':
						$rrules[] = $line;
					break;
					case 'EXRULE':
						$exrules[] = $line;
					break;
					case 'RDATE':
						$rdates = array_merge($rdates, RfcParser::parseRDate($line));
					break;
					case 'EXDATE':
						$exdates = array_merge($exdates, RfcParser::parseExDate($line));
					break;
					default:
						throw new \InvalidArgumentException("Failed to parse RFC, unknown property: $property_name");
				}
			}
			foreach ( $rrules as $rrule ) {
				if ( $dtstart ) {
					$rrule = $dtstart."\n".$rrule;
				}

				$this->addRRule(new RRule($rrule, $default_dtstart));
			}

			foreach ( $exrules as $rrule ) {
				if ( $dtstart ) {
					$rrule = $dtstart."\n".$rrule;
				}
				$this->addExRule(new RRule($rrule, $default_dtstart));
			}

			foreach ( $rdates as $date ) {
				$this->addDate($date);
			}

			foreach ( $exdates as $date ) {
				$this->addExDate($date);
			}
		}
	}

	/**
	 * Add a RRule (or another RSet)
	 *
	 * @param mixed $rrule an instance of RRuleInterface or something that can be transformed into a RRule (string or array)
	 * @return $this
	 */
	public function addRRule($rrule)
	{
		if ( is_string($rrule) || is_array($rrule) ) {
			$rrule = new RRule($rrule);
		}
		elseif ( ! $rrule instanceof RRuleInterface ) {
			throw new \InvalidArgumentException('The rule must be a string, an array, or implement RRuleInterface');
		}

		// cloning because I want to iterate it without being disturbed
		$this->rrules[] = clone $rrule;

		$this->clearCache();

		return $this;
	}

	/**
	 * Return the RRULE(s) contained in this set
	 *
	 * @todo check if a deep copy is needed.
	 *
	 * @return array Array of RRule
	 */
	public function getRRules()
	{
		return $this->rrules;
	}

	/**
	 * Add a RRule with exclusion rules.
	 * In RFC 2445 but deprecated in RFC 5545
	 *
	 * @param mixed $rrule an instance of RRuleInterface or something that can be transformed into a RRule (string or array)
	 * @return $this
	 */
	public function addExRule($rrule)
	{
		if ( is_string($rrule) || is_array($rrule) ) {
			$rrule = new RRule($rrule);
		}
		elseif ( ! $rrule instanceof RRuleInterface ) {
			throw new \InvalidArgumentException('The rule must be a string, an array or implement RRuleInterface');
		}

		// cloning because I want to iterate it without being disturbed
		$this->exrules[] = clone $rrule;

		$this->clearCache();

		return $this;
	}

	/**
	 * Return the EXRULE(s) contained in this set
	 *
	 * @todo check if a deep copy is needed.
	 *
	 * @return array Array of RRule
	 */
	public function getExRules()
	{
		return $this->exrules;
	}

	/**
	 * Add a RDATE (renamed Date for simplicy, since we don't support full RDATE syntax at the moment)
	 *
	 * @param mixed $date a valid date representation or a \DateTime object
	 * @return $this
	 */
	public function addDate($date)
	{
		try {
			$this->rdates[] = RRule::parseDate($date);
			sort($this->rdates);
		} catch (\Exception $e) {
			throw new \InvalidArgumentException(
				'Failed to parse RDATE - it must be a valid date, timestamp or \DateTime object'
			);
		}

		$this->clearCache();

		return $this;
	}

	/**
	 * Return the RDATE(s) contained in this set
	 *
	 * @todo check if a deep copy is needed.
	 *
	 * @return array Array of \DateTime
	 */
	public function getDates()
	{
		return $this->rdates;
	}

	/**
	 * Add a EXDATE
	 *
	 * @param mixed $date a valid date representation or a \DateTime object
	 * @return $this
	 */
	public function addExDate($date)
	{
		try {
			$this->exdates[] = RRule::parseDate($date);
			sort($this->exdates);
		} catch (\Exception $e) {
			throw new \InvalidArgumentException(
				'Failed to parse EXDATE - it must be a valid date, timestamp or \DateTime object'
			);
		}

		$this->clearCache();

		return $this;
	}

	/**
	 * Return the EXDATE(s) contained in this set
	 *
	 * @todo check if a deep copy is needed.
	 *
	 * @return array Array of \DateTime
	 */
	public function getExDates()
	{
		return $this->exdates;
	}

	/**
	 * Clear the cache.
	 * Do NOT use while the class is iterating.
	 * @return $this
	 */
	public function clearCache()
	{
		$this->total = null;
		$this->infinite = null;
		$this->cache = array();

		$this->rlist_heap = null;
		$this->rlist_iterator = null;
		$this->exlist_heap = null;
		$this->exlist_iterator = null;

		return $this;
	}

///////////////////////////////////////////////////////////////////////////////
// RRule interface

	/**
	 * Return true if the rrule has an end condition, false otherwise
	 *
	 * @return bool
	 */
	public function isFinite()
	{
		return ! $this->isInfinite();
	}

	/**
	 * Return true if the rrule has no end condition (infite)
	 *
	 * @return bool
	 */
	public function isInfinite()
	{
		if ( $this->infinite === null ) {
			$this->infinite = false;
			foreach ( $this->rrules as $rrule ) {
				if ( $rrule->isInfinite() ) {
					$this->infinite = true;
					break;
				}
			}
		}
		return $this->infinite;
	}

	/**
	 * Return all the occurrences in an array of \DateTime.
	 *
	 * @param int $limit Limit the resultset to n occurrences (0, null or false = everything)
	 * @return array An array of \DateTime objects
	 */
	public function getOccurrences($limit = null)
	{
		if ( !$limit && $this->isInfinite() ) {
			throw new \LogicException('Cannot get all occurrences of an infinite recurrence set.');
		}

		// cached version already computed
		$iterator = $this;
		if ( $this->total !== null ) {
			$iterator = $this->cache;
		}

		$res = array();
		$n = 0;
		foreach ( $iterator as $occurrence ) {
			$res[] = clone $occurrence; // we have to clone because DateTime is not immutable
			$n += 1;
			if ( $limit && $n >= $limit ) {
				break;
			}
		}
		return $res;
	}

	/**
	 * Return all the ocurrences after a date, before a date, or between two dates.
	 *
	 * @param mixed $begin Can be null to return all occurrences before $end
	 * @param mixed $end Can be null to return all occurrences after $begin
	 * @param int $limit Limit the resultset to n occurrences (0, null or false = everything)
	 * @return array An array of \DateTime objects
	 */
	public function getOccurrencesBetween($begin, $end, $limit = null)
	{
		if ( $begin !== null ) {
			$begin = RRule::parseDate($begin);
		}

		if ( $end !== null ) {
			$end = RRule::parseDate($end);
		}
		elseif ( ! $limit && $this->isInfinite() ) {
			throw new \LogicException('Cannot get all occurrences of an infinite recurrence rule.');
		}

		$iterator = $this;
		if ( $this->total !== null ) {
			$iterator = $this->cache;
		}

		$res = array();
		$n = 0;
		foreach ( $iterator as $occurrence ) {
			if ( $begin !== null && $occurrence < $begin ) {
				continue;
			}
			if ( $end !== null && $occurrence > $end ) {
				break;
			}
			$res[] = clone $occurrence;
			$n += 1;
			if ( $limit && $n >= $limit ) {
				break;
			}
		}
		return $res;
	}

	/**
	 * Return true if $date is an occurrence.
	 *
	 * @param mixed $date
	 * @return bool
	 */
	public function occursAt($date)
	{
		$date = RRule::parseDate($date);

		if ( in_array($date, $this->cache) ) {
			// in the cache (whether cache is complete or not)
			return true;
		}
		elseif ( $this->total !== null ) {
			// cache complete and not in cache
			return false;
		}

		// test if it *should* occur (before exclusion)
		$occurs = false;
		foreach ( $this->rdates as $rdate ) {
			if ( $rdate == $date ) {
				$occurs = true;
				break;
			}
		}
		if ( ! $occurs ) {
			foreach ( $this->rrules as $rrule ) {
				if ( $rrule->occursAt($date) ) {
					$occurs = true;
					break;
				}
			}
		}

		// if it should occur, test if it's excluded
		if ( $occurs ) {
			foreach ( $this->exdates as $exdate ) {
				if ( $exdate == $date ) {
					return false;
				}
			}
			foreach ( $this->exrules as $exrule ) {
				if ( $exrule->occursAt($date) ) {
					return false;
				}
			}
		}

		return $occurs;
	}

///////////////////////////////////////////////////////////////////////////////
// Iterator interface

	/** @internal */
	protected $current = 0;
	/** @internal */
	protected $key = 0;

	/**
	 * @internal
	 */
	public function rewind(): void
	{
		$this->current = $this->iterate(true);
		$this->key = 0;
	}

	/**
	 * @internal
	 */
	public function current()
	{
		return $this->current;
	}

	/**
	 * @internal
	 */
	public function key()
	{
		return $this->key;
	}

	/**
	 * @internal
	 */
	public function next(): void
	{
		$this->current = $this->iterate();
		$this->key += 1;
	}

	/**
	 * @internal
	 */
	public function valid(): bool
	{
		return $this->current !== null;
	}

///////////////////////////////////////////////////////////////////////////////
// ArrayAccess interface

	/**
	 * @internal
	 */
	public function offsetExists($offset): bool
	{
		return is_numeric($offset) && $offset >= 0 && ! is_float($offset) && $offset < count($this);
	}

	/**
	 * @internal
	 */
	public function offsetGet($offset)
	{
		if ( ! is_numeric($offset) || $offset < 0 || is_float($offset) ) {
			throw new \InvalidArgumentException('Illegal offset type: '.gettype($offset));
		}

		if ( isset($this->cache[$offset]) ) {
			// found in cache
			return clone $this->cache[$offset];
		}
		elseif ( $this->total !== null ) {
			// cache complete and not found in cache
			return null;
		}

		// not in cache and cache not complete, we have to loop to find it
		$i = 0;
		foreach ( $this as $occurrence ) {
			if ( $i == $offset ) {
				return $occurrence;
			}
			$i++;
			if ( $i > $offset ) {
				break;
			}
		}
		return null;
	}

	/**
	 * @internal
	 */
	public function offsetSet($offset, $value): void
	{
		throw new \LogicException('Setting a Date in a RSet is not supported (use addDate)');
	}

	/**
	 * @internal
	 */
	public function offsetUnset($offset): void
	{
		throw new \LogicException('Unsetting a Date in a RSet is not supported (use addDate)');
	}

///////////////////////////////////////////////////////////////////////////////
// Countable interface

	/**
	 * Returns the number of recurrences in this set. It will have go
	 * through the whole recurrence, if this hasn't been done before, which
	 * introduces a performance penalty.
	 * @return int
	 */
	public function count(): int
	{
		if ( $this->isInfinite() ) {
			throw new \LogicException('Cannot count an infinite recurrence set.');
		}

		if ( $this->total === null ) {
			foreach ( $this as $occurrence ) {}
		}

		return $this->total;
	}

///////////////////////////////////////////////////////////////////////////////
// Private methods

	// cache variables
	protected $rlist_heap = null;
	protected $rlist_iterator = null;
	protected $exlist_heap = null;
	protected $exlist_iterator = null;

	// local variables for iterate() (see comment in RRule about that)

	/** @internal */
	private $_previous_occurrence = null;
	/** @internal */
	private $_total = 0;
	/** @internal */
	private $_use_cache = 0;

	/**
	 * This method will iterate over a bunch of different iterators (rrules and arrays),
	 * keeping the results *in order*, while never attempting to merge or sort
	 * anything in memory. It can combine both finite and infinite rrule.
	 *
	 * What we need to do it to build two heaps: rlist and exlist
	 * Each heap contains multiple iterators (either RRule or ArrayIterator)
	 * At each step of the loop, it calls all of the iterators to generate a new item,
	 * and stores them in the heap, that keeps them in order.
	 *
	 * This is made slightly more complicated because this method is a generator.
	 *
	 * @param $reset (bool) Whether to restart the iteration, or keep going
	 * @return \DateTime|null
	 */
	protected function iterate($reset = false)
	{
		$previous_occurrence = & $this->_previous_occurrence;
		$total = & $this->_total;
		$use_cache = & $this->_use_cache;

		if ( $reset ) {
			$this->_previous_occurrence = null;
			$this->_total = 0;
			$this->_use_cache = true;
			reset($this->cache);
		}

		// go through the cache first
		if ( $use_cache ) {
			while ( ($occurrence = current($this->cache)) !== false ) {
				next($this->cache);
				$total += 1;
				return clone $occurrence;
			}
			reset($this->cache);
			// now set use_cache to false to skip the all thing on next iteration
			// and start filling the cache instead
			$use_cache = false;
			// if the cache as been used up completely and we now there is nothing else
			if ( $total === $this->total ) {
				return null;
			}
		}

		if ( $this->rlist_heap === null ) {
			// rrules + rdate
			$this->rlist_heap = new \SplMinHeap();
			$this->rlist_iterator = new \MultipleIterator(\MultipleIterator::MIT_NEED_ANY);
			$this->rlist_iterator->attachIterator(new \ArrayIterator($this->rdates));
			foreach ( $this->rrules as $rrule ) {
				$this->rlist_iterator->attachIterator($rrule);
			}
			$this->rlist_iterator->rewind();

			// exrules + exdate
			$this->exlist_heap = new \SplMinHeap();
			$this->exlist_iterator = new \MultipleIterator(\MultipleIterator::MIT_NEED_ANY);

			$this->exlist_iterator->attachIterator(new \ArrayIterator($this->exdates));
			foreach ( $this->exrules as $rrule ) {
				$this->exlist_iterator->attachIterator($rrule);
			}
			$this->exlist_iterator->rewind();
		}

		while ( true ) {
			foreach ( $this->rlist_iterator->current() as $date ) {
				if ( $date !== null ) {
					$this->rlist_heap->insert($date);
				}
			}
			$this->rlist_iterator->next(); // advance the iterator for the next call

			if ( $this->rlist_heap->isEmpty() ) {
				break; // exit the loop to stop the iterator
			}

			$occurrence = $this->rlist_heap->top();
			$this->rlist_heap->extract(); // remove the occurrence from the heap

			if ( $occurrence == $previous_occurrence ) {
				continue; // skip, was already considered
			}

			// now we need to check against exlist
			// we need to iterate exlist as long as it contains dates lower than occurrence
			// (they will be discarded), and then check if the date is the same
			// as occurrence (in which case it is discarded)
			$excluded = false;
			while ( true ) {
				foreach ( $this->exlist_iterator->current() as $date ) {
					if ( $date !== null ) {
						$this->exlist_heap->insert($date);
					}
				}
				$this->exlist_iterator->next(); // advance the iterator for the next call

				if ( $this->exlist_heap->isEmpty() ) {
					break 1; // break this loop only
				}

				$exdate = $this->exlist_heap->top();
				if ( $exdate < $occurrence ) {
					$this->exlist_heap->extract();
					continue;
				}
				elseif ( $exdate == $occurrence ) {
					$excluded = true;
					break 1;
				}
				else {
					break 1; // exdate is > occurrence, so we'll keep it for later
				}
			}

			$previous_occurrence = $occurrence;

			if ( $excluded ) {
				continue;
			}

			$total += 1;
			$this->cache[] = $occurrence;
			return clone $occurrence; // = yield
		}

		$this->total = $total; // save total for count cache
		return null; // stop the iterator
	}
}
