<?php
/**
 * Base date object. Stores date information only (without a time). Internally the date is stored as a
 * unix timestamp, but the time portion of it is not used. If you need a date with a time, use qCal_DateTime
 * @package qCal
 * @subpackage qCal_Date
 * @copyright Luke Visinoni (luke.visinoni@gmail.com)
 * @author Luke Visinoni (luke.visinoni@gmail.com)
 * @license GNU Lesser General Public License
 */
class qCal_Date {

	/**
	 * @var unix timestamp
	 */
	protected $date;
	
	/**
	 * @var int The start day of the week (defaults to Monday)
	 */
	protected $wkst = 1;
	
	/**
	 * @var array The results of a getdate() call
	 */
	protected $dateArray = array();
	
	/**
	 * @var string The date format that is used when outputting via __toString() 
	 */
	protected $format = "m/d/Y";
	
	/**
	 * @var array This is just a mapping of weekdays to 0 (for Sunday) through 6 (for Saturday)
	 * which is a direct correlation with PHP's date function's "w" metacharacter
	 */
	protected $weekdays = array(
		"sunday",
		"monday",
		"tuesday",
		"wednesday",
		"thursday",
		"friday",
		"saturday",
	);
	
	protected $weekdayAbbr = array(
		"SU" => "sunday",
		"MO" => "monday",
		"TU" => "tuesday",
		"WE" => "wednesday",
		"TH" => "thursday",
		"FR" => "friday",
		"SA" => "saturday",
	);
	
	/**
	 * @var array This is an array of months starting at 1 and ending on 12
	 */
	protected $months = array(
		1 =>  "january",
		2 =>  "february",
		3 =>  "march",
		4 =>  "april",
		5 =>  "may",
		6 =>  "june",
		7 =>  "july",
		8 =>  "august",
		9 =>  "september",
		10 => "october",
		11 => "november",
		12 => "december",
	);
	
	/**
	 * @var array The month in a two-dimensional array (picture a calendar)
	 */
	protected $monthMap = array();
	
	/**
	 * Class constructor
	 * @param int The year of this date
	 * @param int The month of this date
	 * @param int The day of this date
	 * @access public
	 */
	public function __construct($year = null, $month = null, $day = null, $rollover = false) {
	
		$this->setDate($year, $month, $day, $rollover);
	
	}
	
	/**
	 * Set the date of this class
	 * The date defaults to now. If any part of the date is missing, it will default to whatever "now"'s
	 * date portion is. For instance, if the year provided is 2006 and no other portion is given, it will
	 * default to today's month and day, but in the year 2006. If, for any reason the date defaults to a 
	 * nonsensical date, an exception will be thrown. For instance, if you specify the year as 2006, and
	 * the current date is february 29th, an exception will be thrown because the 29th of February does not
	 * exist in 2006. 
	 * @param int The year of this date
	 * @param int The month of this date
	 * @param int The day of this date
	 * @return $this
	 * @access protected
	 * @throws qCal_Date_Exception_InvalidDate
	 */
	protected function setDate($year = null, $month = null, $day = null, $rollover = false) {
	
		$now = getdate();
		if (is_null($year)) {
			$year = $now['year'];
		}
		if (is_null($month)) {
			$month = $now['mon'];
		}
		if (is_null($day)) {
			$day = $now['mday'];
		}
		
		$this->date = gmmktime(0, 0, 0, $month, $day, $year);
		$this->dateArray = self::gmgetdate($this->date);
		if (!$rollover) {
			if ($this->dateArray["mday"] != $day || $this->dateArray["mon"] != $month || $this->dateArray["year"] != $year) {
				throw new qCal_DateTime_Exception_InvalidDate("Invalid date specified for qCal_Date: \"{$month}/{$day}/{$year}\"");
			}
		}
		
		// @todo Look into how much more efficient it might be to call date() only once and then break apart the result...
		$formatString = "d|D|j|l|N|S|w|z|W|F|m|M|n|t|L|o|y|Y|c|r|U";
		$keys = explode("|", $formatString);
		$vals = explode("|", gmdate($formatString, $this->date));
		$this->dateArray = array_merge($this->dateArray, array_combine($keys, $vals));
		return $this;
	
	}
	
	/**
	 * This is a factory method. It allows you to create a date by string or by another date object (to make a copy)
	 * @param mixed Either unix timestamp (integer) or a string representing a date
	 * @return qCal_Date The date for the param passed in
	 * @access public
	 */
	public static function factory($date) {
	
		if (is_integer($date)) {
			// @todo Handle timestamps
		}
		if (is_string($date)) {
			if (!$timestamp = strtotime($date)) {
				// if unix timestamp can't be created throw an exception
				throw new qCal_Date_Exception_InvalidDate("Invalid or ambiguous date string passed to qCal_Date::factory()");
			}
		}
		
		$date = self::gmgetdate($timestamp);
		$newdate = gmmktime(0, 0, 0, $date['mon'], $date['mday'], $date['year']);
		$newdate = self::gmgetdate($newdate);
		return new qCal_Date($newdate['year'], $newdate['mon'], $newdate['mday']);
	
	}
	
	/**
	 * Set the format that should be used when calling either __toString() or format() without an argument.
	 * @param string $format
	 * @return $this
	 * @access public
	 */
	public function setFormat($format) {
	
		$this->format = (string) $format;
		return $this;
	
	}
	
	/**
	 * Formats the date according to either the existing $this->format, or if the $format arg is passed
	 * in, it uses that.
	 * @param string The format that is to be used (according to php's date function). Only date-related metacharacters work.
	 * @return string This date, formatted as a string, according to the format string passed in
	 * @access public
	 */
	public function format($format) {
	
		$escape = false;
		$meta = str_split($format);
		$output = array();
		foreach($meta as $char) {
			if ($char == '\\') {
				$escape = true;
				continue;
			}
			if (!$escape && array_key_exists($char, $this->dateArray)) {
				$output[] = $this->dateArray[$char];
			} else {
				$output[] = $char;
			}
			// reset this to false after every iteration that wasn't "continued"
			$escape = false;
		}
		return implode($output);
	
	}
	
	/**
	 * Getters
	 * The next dozen or so methods are just your standard getters for things such as month, day, year, week day, etc.
	 */
	
	/**
	 * Get the month (number) of this date
	 * @return integer A number between 1 and 12 inclusively
	 * @access public
	 */
	public function getMonth() {
	
		return (integer) $this->dateArray["mon"];
	
	}
	
	/**
	 * Get the month of this date
	 * @return string The actual name of the month, capitalized
	 * @access public
	 */
	public function getMonthName() {
	
		return $this->dateArray["month"];
	
	}
	
	/**
	 * Get the day of the month
	 * @return integer A number between 1 and 31 inclusively
	 * @access public
	 */
	public function getDay() {
	
		return (integer) $this->dateArray["mday"];
	
	}
	
	/**
	 * Get the day of the year
	 * @return integer A number between 0 and 365 inclusively
	 * @access public
	 */
	public function getYearDay($startFromOne = false) {
	
		$yearDay = $this->dateArray["yday"] + (integer) $startFromOne;
		return (integer) $yearDay;
	
	}
	
	/**
	 * Find how many days until the end of the year.
	 * For instance, if the date is December 25th, there are 6 days until the end of the year
	 * @return integer The number of days until the end of the year
	 * @access public
	 */
	public function getNumDaysUntilEndOfYear() {
	
		$yearday = $this->getYearDay(true);
		return (integer) ($this->getNumDaysInYear() - $yearday);
	
	}
	
	/**
	 * Get how many months until the end of the year
	 * @return integer The number of months until the end of the year
	 * @access public
	 * @todo This is really rudimentary. There is more to this, but this works for now...
	 */
	public function getNumMonthsUntilEndOfYear() {
	
		return (integer) (12 - $this->getMonth());
	
	}
	
	/**
	 * Get the amount of days in the year (365 unless it is a leap-year, then it's 366)
	 * @return integer The number of days in the year
	 * @access public
	 */
	public function getNumDaysInYear() {
	
		$num = ($this->isLeapYear()) ? 366 : 365;
		return (integer) $num;
	
	}
	
	/**
	 * Return the first day of the month as a qCal_Date object
	 * @return qCal_Date The first day of the month
	 * @access public
	 */
	public function getFirstDayOfMonth() {
	
		return new qCal_Date($this->getYear(), $this->getMonth(), 1);
	
	}
	
	/**
	 * Return the last day of the month as a qCal_Date object
	 * @return qCal_Date The last day of the month
	 * @access public
	 */
	public function getLastDayOfMonth() {
	
		$lastday = $this->format("t");
		return new qCal_Date($this->getYear(), $this->getMonth(), $lastday);
	
	}
	
	/**
	 * Get the number of days until the end of the month
	 * @return integer The number of days until the end of the month
	 * @access public
	 */
	public function getNumDaysUntilEndOfMonth() {
	
		return (integer) ($this->getNumDaysInMonth() - $this->getDay());
	
	}
	
	/**
	 * Get the year
	 * @return integer The year of this date, for example 1999
	 * @access public
	 */
	public function getYear() {
	
		return (integer) $this->dateArray["year"];
	
	}
	
	/**
	 * Get the day of the week 
	 * @return integer A number between 0 (for Sunday) and 6 (for Saturday).
	 * @access public
	 */
	public function getWeekDay() {
	
		return (integer) $this->dateArray["wday"];
	
	}
	
	/**
	 * Get the day of the week
	 * @return string The actual name of the day of the week, capitalized
	 * @access public
	 */
	public function getWeekDayName() {
	
		return $this->dateArray["weekday"];
	
	}
	
	/**
	 * Get the amount of days in the current month of this year
	 * @return integer The number of days in the month
	 * @access public
	 */
	public function getNumDaysInMonth() {
	
		return (integer) $this->dateArray["t"];
	
	}
	
	/**
	 * Get the week of the year
	 * @return integer The week of the year (0-51 I think)
	 * @access public
	 * @todo This is not accurate if the week start isn't monday. I need to adjust for that
	 */
	public function getWeekOfYear() {
	
		return (integer) $this->dateArray["W"];
	
	}
	
	/**
	 * Get how many weeks until the end of the year
	 * @access public
	 * @todo This is really rudimentary. There is more to this, but this works for now...
	 */
	public function getWeeksUntilEndOfYear() {
	
		return (integer) (52 - $this->getWeekOfYear());
	
	}
	
	/**
	 * Determine if this is a leap year
	 * @return boolean Whether or not this date falls on a leap year
	 * @access public
	 */
	public function isLeapYear() {
	
		return (boolean) $this->dateArray["L"];
	
	}
	
	/**
	 * Get a unix timestamp for the date
	 * @return integer The amount of seconds since unix epoch (January 1, 1970 UTC)
	 * @access public
	 */
	public function getUnixTimestamp() {
	
		return (integer) $this->dateArray[0];
	
	}
	
	/**
	 * Date magic
	 * This component is capable of doing some really convenient things with dates.
	 * It is capable of determining things such as how many days until the end of the year,
	 * which monday of the month it is (ie: third monday in february), etc.
	 */
	
	/**
	 * Determine the number or Tuesdays (or whatever day of the week this date is) since the
	 * beginning or end of the month.
	 * @param integer $xth A positive or negative number that determines which weekday of the month we want
	 * @param string|integer $weekday Either Sunday-Saturday or 0-6 to specify the weekday we want (can also be SU through SA)
	 * @param string|integer $month Either January-December or 1-12 to specify the month we want
	 * @param integer $year A valid year to specify which year we want
	 * @return qCal_Date An object representing the xth day of the month
	 * @access public
	 */
	public function getXthWeekdayOfMonth($xth, $weekday = null, $month = null, $year = null) {
	
		$negpos = substr($xth, 0, 1);
		if ($negpos == "+" || $negpos == "-") {
			$xth = (integer) substr($xth, 1);
		} else {
			$negpos = "+";
		}
		
		if (is_null($weekday)) {
			$weekday = $this->getWeekday();
		}
		
		if (ctype_digit((string) $weekday)) {
			if (!array_key_exists($weekday, $this->weekdays)) {
				throw new qCal_Date_Exception_InvalidWeekday("\"$weekday\" is not a valid weekday.");
			}
		} elseif (strlen($weekday) == 2) {
			// if it isn't in the full weekdays, check the abbreviated list
			if (array_key_exists($weekday, $this->weekdayAbbr)) {
				$weekday = $this->weekdayAbbr[$weekday];
				$wdays = array_flip($this->weekdays);
				$weekday = $wdays[$weekday];
			}
		} else {
			$weekday = strtolower($weekday);
			if (!in_array($weekday, $this->weekdays)) {
				throw new qCal_Date_Exception_InvalidWeekday("\"$weekday\" is not a valid weekday.");
			}
			$wdays = array_flip($this->weekdays);
			$weekday = $wdays[$weekday];
		}
		
		if (is_null($month)) {
			$month = $this->getMonth();
		}
		
		if (ctype_digit((string) $month)) {
			if (!array_key_exists($month, $this->months)) {
				throw new qCal_Date_Exception_InvalidMonth("\"$month\" is not a valid month.");
			}
		} else {
			$month = strtolower($month);
			if (!in_array($month, $this->months)) {
				throw new qCal_Date_Exception_InvalidMonth("\"$month\" is not a valid month.");
			}
			$mons = array_flip($this->months);
			$month = $mons[$month];
		}
		
		if (is_null($year)) {
			$year = $this->getYear();
		}
		
		if (!ctype_digit((string) $year) || strlen($year) != 4) {
			throw new qCal_Date_Exception_InvalidYear("\"$year\" is not a valid year.");
		}
		
		// now, using the year, month and numbered weekday, we need to find the actual day of the month...
		$firstofmonth = new qCal_Date($year, $month, 1);
		$numdaysinmonth = $firstofmonth->getNumDaysInMonth();
		$numweekdays = 0; // the number of weekdays that have occurred (in the loop)
		$foundday = false;
		if ($negpos == "+") {
			$day = 1;
			$wday = $firstofmonth->getWeekday();
			// while we are in the current month, loop
			while ($day <= $numdaysinmonth) {
				// if the specified weekday == the current week day in the loop
				if ($weekday == $wday) {
					$numweekdays++;
					if ($numweekdays == $xth) {
						// break out of the loop, we've found the right day! yay!
						$foundday = $day;
						break;
					}
				}
				if ($wday == 6) $wday = 0; // reset to Sunday after Saturday
				else $wday++;
				$day++;
			}
		} else {
			$day = $numdaysinmonth;
			$lastofmonth = $firstofmonth->getLastDayOfMonth();
			$wday = $lastofmonth->getWeekday();
			while ($day >= 1) {
				if ($weekday == $wday) {
					$numweekdays++;
					if ($numweekdays == $xth) {
						// break out of the loop, we've found the right day! yay!
						$foundday = $day;
						break;
					}
				}
				if ($wday == 0) $wday = 6; // reset to Saturday after Sunday
				else $wday--;
				$day--;
			}
		}
		
		if ($foundday && checkdate($month, $day, $year)) {
			$date = new qCal_Date($year, $month, $day);
		} else {
			if ($day == 32) {
				throw new qCal_DateTime_Exception_InvalidDate("You have specified an incorrect number of days for qCal_Date::getXthWeekdayOfMonth()");
			} else {
				throw new qCal_DateTime_Exception_InvalidDate("You have entered an invalid date.");
			}
		}
		
		return $date;
	
	}
	
	/**
	 * Determine the number or Tuesdays (or whatever day of the week this date is) since the
	 * beginning or end of the year.
	 * @param integer $xth A positive or negative number that determines which weekday of the year we want
	 * @param string|integer $weekday Either Sunday-Saturday or 0-6 to specify the weekday we want (can also be SU through SA)
	 * @param integer $year A valid year to specify which year we want
	 * @return qCal_Date An object representing the xth day of the month
	 * @access public
	 */
	public function getXthWeekdayOfYear($xth, $weekday = null, $year = null) {
	
		$negpos = substr($xth, 0, 1);
		if ($negpos == "+" || $negpos == "-") {
			$xth = (integer) substr($xth, 1);
		} else {
			$negpos = "+";
		}
		
		if (is_null($weekday)) {
			$weekday = $this->getWeekday();
		}
		
		if (ctype_digit((string) $weekday)) {
			if (!array_key_exists($weekday, $this->weekdays)) {
				throw new qCal_Date_Exception_InvalidWeekday("\"$weekday\" is not a valid weekday.");
			}
		} elseif (strlen($weekday) == 2) {
			// if it isn't in the full weekdays, check the abbreviated list
			if (array_key_exists($weekday, $this->weekdayAbbr)) {
				$weekday = $this->weekdayAbbr[$weekday];
				$wdays = array_flip($this->weekdays);
				$weekday = $wdays[$weekday];
			}
		} else {
			$weekday = strtolower($weekday);
			if (!in_array($weekday, $this->weekdays)) {
				throw new qCal_Date_Exception_InvalidWeekday("\"$weekday\" is not a valid weekday.");
			}
			$wdays = array_flip($this->weekdays);
			$weekday = $wdays[$weekday];
		}
		
		if (is_null($year)) {
			$year = $this->getYear();
		}
		
		if (!ctype_digit((string) $year) || strlen($year) != 4) {
			throw new qCal_Date_Exception_InvalidYear("\"$year\" is not a valid year.");
		}
		
		// now find the specified day by counting either forwards or backwards to the day in question
		$firstofyear = new qCal_Date($year, 1, 1);
		$numdaysinyear = ($firstofyear->isLeapYear()) ? 366 : 365;
		$numweekdays = 0; // the number of weekdays that have occurred within the loop
		$found = false; // whether or not the specified day has been found
		if ($negpos == "+") {
			// count forward
			// loop over every day of every month looking for the right one
			$day = 1;
			$wday = $firstofyear->getWeekDay();
			while ($day <= $numdaysinyear) {
				// if the specified weekday == the current week day in the loop
				if ($weekday == $wday) {
					$numweekdays++;
					if ($numweekdays == $xth) {
						// break out of the loop, we've found the right day! yay!
						$found = $day;
						break;
					}
				}
				if ($wday == 6) $wday = 0; // reset to Sunday after Saturday
				else $wday++;
				$day++;
			}
		} else {
			// count backward
			$lastofyear = new qCal_Date($year, 12, 31);
			// count forward
			// loop over every day of every month looking for the right one
			$day = $numdaysinyear;
			$wday = $lastofyear->getWeekDay();
			while ($day >= 1) {
				// if the specified weekday == the current week day in the loop
				if ($weekday == $wday) {
					$numweekdays++;
					if ($numweekdays == $xth) {
						// break out of the loop, we've found the right day! yay!
						$found = $day;
						break;
					}
				}
				if ($wday == 0) $wday = 6; // reset to Saturday after Sunday
				else $wday--;
				$day--;
			}
		}
		
		// @todo: Can't use checkdate here, so find another validation method...
		if ($found) {
			$date = new qCal_Date($year, 1, $found, true); // takes advantage of the rollover feature :)
		} else {
			throw new qCal_DateTime_Exception_InvalidDate("You have specified an incorrect number of days for qCal_Date::getXthWeekdayOfYear()");
		}
		
		return $date;
	
	}
	
	/**
	 * Magic methods
	 */
	
	/**
	 * Output the date as a string. Options are as follows:
	 * @return string The formatted date
	 * @access public
	 */
	public function __toString() {
	
		return $this->format($this->format);
	
	}
	
	/**
	 * Static methods
	 */
	
	/**
	 * Because PHP does not provide a gmgetdate() function, I borrowed this one from the
	 * comments on the getdate() function page on php.net
	 * @param integer The timestamp to use to create the date
	 * @return array The same array as would be returned by PHP's native getdate() function
	 * @access public
	 * @static
	 */
	public static function gmgetdate($timestamp = null) {
	
		$k = array('seconds','minutes','hours','mday','wday','mon','year','yday','weekday','month',0);
		return(array_combine($k, explode(":", gmdate('s:i:G:j:w:n:Y:z:l:F:U', is_null($timestamp) ? time() : $timestamp))));
	
	}

}
