<?php
/**
 * @file
 * ApnsPHP_Push class definition.
 *
 * LICENSE
 *
 * This source file is subject to the new BSD license that is bundled
 * with this package in the file LICENSE.txt.
 * It is also available through the world-wide-web at this URL:
 * http://code.google.com/p/apns-php/wiki/License
 * If you did not receive a copy of the license and are unable to
 * obtain it through the world-wide-web, please send an email
 * to aldo.armiento@gmail.com so we can send you a copy immediately.
 *
 * @author (C) 2010 Aldo Armiento (aldo.armiento@gmail.com)
 * @version $Id$
 */

/**
 * @defgroup ApnsPHP_Push Push
 * @ingroup ApplePushNotificationService
 */

/**
 * The Push Notification Provider.
 *
 * The class manages a message queue and sends notifications payload to Apple Push
 * Notification Service.
 *
 * @ingroup ApnsPHP_Push
 */
class ApnsPHP_Push extends ApnsPHP_Abstract
{
	const COMMAND_PUSH = 1; /**< @type integer Payload command. */

	const ERROR_RESPONSE_SIZE = 6; /**< @type integer Error-response packet size. */
	const ERROR_RESPONSE_COMMAND = 8; /**< @type integer Error-response command code. */

	const STATUS_CODE_INTERNAL_ERROR = 999; /**< @type integer Status code for internal error (not Apple). */

	protected $_aErrorResponseMessages = array(
		0	=> 'No errors encountered',
		1	=> 'Processing error',
		2	=> 'Missing device token',
		3	=> 'Missing topic',
		4	=> 'Missing payload',
		5	=> 'Invalid token size',
		6	=> 'Invalid topic size',
		7	=> 'Invalid payload size',
		8	=> 'Invalid token',
		self::STATUS_CODE_INTERNAL_ERROR => 'Internal error'
	); /**< @type array Error-response messages. */

	protected $_nSendRetryTimes = 3; /**< @type integer Send retry times. */

	protected $_aServiceURLs = array(
		'ssl://gateway.push.apple.com:2195', // Production environment
		'ssl://gateway.sandbox.push.apple.com:2195' // Sandbox environment
	); /**< @type array Service URLs environments. */

	protected $_aMessageQueue = array(); /**< @type array Message queue. */
	protected $_aErrors = array(); /**< @type array Error container. */

	/**
	 * Set the send retry times value.
	 *
	 * If the client is unable to send a payload to to the server retries at least
	 * for this value. The default send retry times is 3.
	 *
	 * @param  $nRetryTimes @type integer Send retry times.
	 */
	public function setSendRetryTimes($nRetryTimes)
	{
		$this->_nSendRetryTimes = (int)$nRetryTimes;
	}

	/**
	 * Get the send retry time value.
	 *
	 * @return @type integer Send retry times.
	 */
	public function getSendRetryTimes()
	{
		return $this->_nSendRetryTimes;
	}

	/**
	 * Adds a message to the message queue.
	 *
	 * @param  $message @type ApnsPHP_Message The message.
	 */
	public function add(ApnsPHP_Message $message)
	{
		$sMessagePayload = $message->getPayload();
		$nRecipients = $message->getRecipientsNumber();

		$nMessageQueueLen = count($this->_aMessageQueue);
		for ($i = 0; $i < $nRecipients; $i++) {
			$nMessageID = $nMessageQueueLen + $i + 1;
			$this->_aMessageQueue[$nMessageID] = array(
				'MESSAGE' => $message,
				'BINARY_NOTIFICATION' => $this->_getBinaryNotification(
					$message->getRecipient($i),
					$sMessagePayload,
					$nMessageID,
					$message->getExpiry()
				),
				'ERRORS' => array()
			);
		}
	}

	/**
	 * Sends all messages in the message queue to Apple Push Notification Service.
	 *
	 * @throws ApnsPHP_Push_Exception if not connected to the
	 *		   service or no notification queued.
	 */
	public function send()
	{
		if (!$this->_hSocket) {
			throw new ApnsPHP_Push_Exception(
				'Not connected to Push Notification Service'
			);
		}

		if (empty($this->_aMessageQueue)) {
			throw new ApnsPHP_Push_Exception(
				'No notifications queued to be sent'
			);
		}

		$this->_aErrors = array();
		$nRun = 1;
		while (($nMessages = count($this->_aMessageQueue)) > 0) {
			$this->_log("INFO: Sending messages queue, run #{$nRun}: $nMessages message(s) left in queue.");

			$bError = false;
			foreach($this->_aMessageQueue as $k => &$aMessage) {
				if (function_exists('pcntl_signal_dispatch')) {
					pcntl_signal_dispatch();
				}

				$message = $aMessage['MESSAGE'];
				$sCustomIdentifier = (string)$message->getCustomIdentifier();
				$sCustomIdentifier = sprintf('[custom identifier: %s]', empty($sCustomIdentifier) ? 'unset' : $sCustomIdentifier);

				$nErrors = 0;
				if (!empty($aMessage['ERRORS'])) {
					foreach($aMessage['ERRORS'] as $aError) {
						if ($aError['statusCode'] == 0) {
							$this->_log("INFO: Message ID {$k} {$sCustomIdentifier} has no error ({$aError['statusCode']}), removing from queue...");
							$this->_removeMessageFromQueue($k);
							continue 2;
						} else if ($aError['statusCode'] > 1 && $aError['statusCode'] <= 8) {
							$this->_log("WARNING: Message ID {$k} {$sCustomIdentifier} has an unrecoverable error ({$aError['statusCode']}), removing from queue without retrying...");
							$this->_removeMessageFromQueue($k, true);
							continue 2;
						}
					}
					if (($nErrors = count($aMessage['ERRORS'])) >= $this->_nSendRetryTimes) {
						$this->_log(
							"WARNING: Message ID {$k} {$sCustomIdentifier} has {$nErrors} errors, removing from queue..."
						);
						$this->_removeMessageFromQueue($k, true);
						continue;
					}
				}

				$nLen = strlen($aMessage['BINARY_NOTIFICATION']);
				$this->_log("STATUS: Sending message ID {$k} {$sCustomIdentifier} (" . ($nErrors + 1) . "/{$this->_nSendRetryTimes}): {$nLen} bytes.");

				$aErrorMessage = null;
				if ($nLen !== ($nWritten = (int)@fwrite($this->_hSocket, $aMessage['BINARY_NOTIFICATION']))) {
					$aErrorMessage = array(
						'identifier' => $k,
						'statusCode' => self::STATUS_CODE_INTERNAL_ERROR,
						'statusMessage' => sprintf('%s (%d bytes written instead of %d bytes)',
							$this->_aErrorResponseMessages[self::STATUS_CODE_INTERNAL_ERROR], $nWritten, $nLen
						)
					);
				}
				usleep($this->_nWriteInterval);

				$bError = $this->_updateQueue($aErrorMessage);
				if ($bError) {
					break;
				}
			}

			if (!$bError) {
				$read = array($this->_hSocket);
				$null = NULL;
				$nChangedStreams = @stream_select($read, $null, $null, 0, $this->_nSocketSelectTimeout);
				if ($nChangedStreams === false) {
					$this->_log('ERROR: Unable to wait for a stream availability.');
					break;
				} else if ($nChangedStreams > 0) {
					$bError = $this->_updateQueue();
					if (!$bError) {
						$this->_aMessageQueue = array();
					}
				} else {
					$this->_aMessageQueue = array();
				}
			}

			$nRun++;
		}
	}

	/**
	 * Returns messages in the message queue.
	 *
	 * When a message is successful sent or reached the maximum retry time is removed
	 * from the message queue and inserted in the Errors container. Use the getErrors()
	 * method to retrive messages with delivery error(s).
	 *
	 * @param  $bEmpty @type boolean @optional Empty message queue.
	 * @return @type array Array of messages left on the queue.
	 */
	public function getQueue($bEmpty = true)
	{
		$aRet = $this->_aMessageQueue;
		if ($bEmpty) {
			$this->_aMessageQueue = array();
		}
		return $aRet;
	}

	/**
	 * Returns messages not delivered to the end user because one (or more) error
	 * occurred.
	 *
	 * @param  $bEmpty @type boolean @optional Empty message container.
	 * @return @type array Array of messages not delivered because one or more errors
	 *		   occurred.
	 */
	public function getErrors($bEmpty = true)
	{
		$aRet = $this->_aErrors;
		if ($bEmpty) {
			$this->_aErrors = array();
		}
		return $aRet;
	}

	/**
	 * Generate a binary notification from a device token and a JSON-encoded payload.
	 *
	 * @see http://tinyurl.com/ApplePushNotificationBinary
	 *
	 * @param  $sDeviceToken @type string The device token.
	 * @param  $sPayload @type string The JSON-encoded payload.
	 * @param  $nMessageID @type integer @optional Message unique ID.
	 * @param  $nExpire @type integer @optional Seconds, starting from now, that
	 *		   identifies when the notification is no longer valid and can be discarded.
	 *		   Pass a negative value (-1 for example) to request that APNs not store
	 *		   the notification at all. Default is 86400 * 7, 7 days.
	 * @return @type string A binary notification.
	 */
	protected function _getBinaryNotification($sDeviceToken, $sPayload, $nMessageID = 0, $nExpire = 604800)
	{
		$nTokenLength = strlen($sDeviceToken);
		$nPayloadLength = strlen($sPayload);

		$sRet  = pack('CNNnH*', self::COMMAND_PUSH, $nMessageID, $nExpire > 0 ? time() + $nExpire : 0, self::DEVICE_BINARY_SIZE, $sDeviceToken);
		$sRet .= pack('n', $nPayloadLength);
		$sRet .= $sPayload;

		return $sRet;
	}

	/**
	 * Parses the error message.
	 *
	 * @param  $sErrorMessage @type string The Error Message.
	 * @return @type array Array with command, statusCode and identifier keys.
	 */
	protected function _parseErrorMessage($sErrorMessage)
	{
		return unpack('Ccommand/CstatusCode/Nidentifier', $sErrorMessage);
	}

	/**
	 * Reads an error message (if present) from the main stream.
	 * If the error message is present and valid the error message is returned,
	 * otherwhise null is returned.
	 *
	 * @return @type array|null Return the error message array.
	 */
	protected function _readErrorMessage()
	{
		$sErrorResponse = @fread($this->_hSocket, self::ERROR_RESPONSE_SIZE);
		if ($sErrorResponse === false || strlen($sErrorResponse) != self::ERROR_RESPONSE_SIZE) {
			return;
		}
		$aErrorResponse = $this->_parseErrorMessage($sErrorResponse);
		if (!is_array($aErrorResponse) || empty($aErrorResponse)) {
			return;
		}
		if (!isset($aErrorResponse['command'], $aErrorResponse['statusCode'], $aErrorResponse['identifier'])) {
			return;
		}
		if ($aErrorResponse['command'] != self::ERROR_RESPONSE_COMMAND) {
			return;
		}
		$aErrorResponse['time'] = time();
		$aErrorResponse['statusMessage'] = 'None (unknown)';
		if (isset($this->_aErrorResponseMessages[$aErrorResponse['statusCode']])) {
			$aErrorResponse['statusMessage'] = $this->_aErrorResponseMessages[$aErrorResponse['statusCode']];
		}
		return $aErrorResponse;
	}

	/**
	 * Checks for error message and deletes messages successfully sent from message queue.
	 *
	 * @param  $aErrorMessage @type array @optional The error message. It will anyway
	 *		   always be read from the main stream. The latest successful message
	 *		   sent is the lowest between this error message and the message that
	 *		   was read from the main stream.
	 *		   @see _readErrorMessage()
	 * @return @type boolean True if an error was received.
	 */
	protected function _updateQueue($aErrorMessage = null)
	{
		$aStreamErrorMessage = $this->_readErrorMessage();
		if (!isset($aErrorMessage) && !isset($aStreamErrorMessage)) {
			return false;
		} else if (isset($aErrorMessage, $aStreamErrorMessage)) {
			if ($aStreamErrorMessage['identifier'] <= $aErrorMessage['identifier']) {
				$aErrorMessage = $aStreamErrorMessage;
				unset($aStreamErrorMessage);
			}
		} else if (!isset($aErrorMessage) && isset($aStreamErrorMessage)) {
			$aErrorMessage = $aStreamErrorMessage;
			unset($aStreamErrorMessage);
		}

		$this->_log('ERROR: Unable to send message ID ' .
			$aErrorMessage['identifier'] . ': ' .
			$aErrorMessage['statusMessage'] . ' (' . $aErrorMessage['statusCode'] . ').');

		$this->disconnect();

		foreach($this->_aMessageQueue as $k => &$aMessage) {
			if ($k < $aErrorMessage['identifier']) {
				unset($this->_aMessageQueue[$k]);
			} else if ($k == $aErrorMessage['identifier']) {
				$aMessage['ERRORS'][] = $aErrorMessage;
			} else {
				break;
			}
		}

		$this->connect();

		return true;
	}

	/**
	 * Remove a message from the message queue.
	 *
	 * @param  $nMessageID @type integer The Message ID.
	 * @param  $bError @type boolean @optional Insert the message in the Error container.
	 * @throws ApnsPHP_Push_Exception if the Message ID is not valid or message
	 *		   does not exists.
	 */
	protected function _removeMessageFromQueue($nMessageID, $bError = false)
	{
		if (!is_numeric($nMessageID) || $nMessageID <= 0) {
			throw new ApnsPHP_Push_Exception(
				'Message ID format is not valid.'
			);
		}
		if (!isset($this->_aMessageQueue[$nMessageID])) {
			throw new ApnsPHP_Push_Exception(
				"The Message ID {$nMessageID} does not exists."
			);
		}
		if ($bError) {
			$this->_aErrors[$nMessageID] = $this->_aMessageQueue[$nMessageID];
		}
		unset($this->_aMessageQueue[$nMessageID]);
	}
}