<?php

/**
 * CalDAV calendaring connection class
 * 
 * @package Atmail
 * @subpackage Calendar 
 * @copyright Copyright (C) 2011 Atmail. All rights reserved.
 * @author Evert Pot (http://www.rooftopsolutions.nl/) 
 */
class Atmail_Calendar_Adapter_CalDAV extends Atmail_Calendar_Adapter_Abstract {

    /**
     * WebDAV client class
     * 
     * @var Sabre_DAV_Client 
     */
    protected $_dav;

    /**
     * WebDAV username
     * 
     * @var string 
     */
    protected $_userName;
      
    /**
     * Used to prefix all cache keys
     * 
     * @var string 
     */
    protected $_cachePrefix;

    /**
     * The list of urls that may contain a users' calendars. 
     * 
     * @var array 
     */
    protected $_calendarHomeSet = null;

    /**
     * The url to the principal 
     * 
     * @var string 
     */
    protected $_principalUrl;

    public static $forceRefresh = false;


    const PROP_CURRENTUSERPRIVILEGESET = '{DAV:}current-user-privilege-set';
    const PROP_CTAG = '{http://calendarserver.org/ns/}getctag';
    const PROP_DESCRIPTION = '{urn:ietf:params:xml:ns:caldav}calendar-description';
    const PROP_ORDER = '{http://apple.com/ns/ical/}calendar-order';
    const PROP_COLOR = '{http://apple.com/ns/ical/}calendar-color';
    const PROP_CALENDARDATA = '{urn:ietf:params:xml:ns:caldav}calendar-data';
    const PROP_OWNER = '{DAV:}owner';

    /**
     * Constructor 
     *
     * The url, userName and password settings are required
     *
     * @param array $settings 
     */
    public function __construct(array $settings) {

        $required = array(
            'url',
            'userName',
            'password',
        );

        foreach($required as $rr) {
            if (!isset($settings[$rr])) {
                throw new InvalidArgumentException('The ' . $rr . ' setting is required');
            }
        }

        $this->_dav = new Sabre_DAV_Client(array(
            'baseUri' => $settings['url'],
            'userName' => $settings['userName'],
            'password' => $settings['password'],
        ));

        $this->_userName = $settings['userName'];

        $this->_dav->propertyMap['{DAV:}current-user-principal'] = 'Sabre_DAV_Property_Href';
        $this->_dav->propertyMap['{' . Sabre_CalDAV_Plugin::NS_CALDAV . '}calendar-home-set'] = 'Sabre_DAV_Property_HrefList';

   	    $this->_cachePrefix = get_class($this) . ':' . md5($settings['url'] . ':' . $settings['userName'] . ':' . $settings['password']) . ':';
    }

	private function generateCacheId( $namespace )
	{
		return $this->_cachePrefix . ':' . $namespace;
	}

    /**
     * Returns a list of calendars 
     * 
     * @return Atmail_Calendar_Calendar[] 
     */
    public function getCalendars()
    {

        $calendarHomeSet = $this->getCalendarHomeSet();
        $principalUrl = $this->getPrincipalUrl();
        $proxyFor = $this->getProxiedPrincipals();

        $writeCalendars = array_unique(array_merge(array_merge($proxyFor['write']), $calendarHomeSet));
        $readCalendars = $proxyFor['read'];
        
        $foundCalendars = array();
        $calendars = array();
        
        foreach($writeCalendars as $calendarHome)
        {
        	$foundCalendars[] = array('href' => $calendarHome, 'mode' => 'write');
        }
        foreach($readCalendars as $calendarHome)
        {
        	$foundCalendars[] = array('href' => $calendarHome, 'mode' => 'read');
        }
        
        foreach($foundCalendars as $calendarHome)
        {

        	$result = $this->_dav->propfind($calendarHome['href'], array(
                '{DAV:}resourcetype',
                '{DAV:}displayname',
                self::PROP_CTAG,
                self::PROP_DESCRIPTION,
                self::PROP_ORDER,
                self::PROP_COLOR,
                self::PROP_OWNER,
            ),1);
            foreach($result as $url=>$properties)
            { 

                // No resourcetype.. ignoring
                if (!isset($properties['{DAV:}resourcetype']) || !$properties['{DAV:}resourcetype']) {
                    continue;
                }

                // Not a calendar.. ignoring
                if (!$properties['{DAV:}resourcetype']->is('{' . Sabre_CalDAV_Plugin::NS_CALDAV . '}calendar')) {
                    continue;
                }

                $calendar = $this->convertPropertiesToCalendar($url, $properties);                
                $calendar->readOnly = ($calendarHome['mode'] == 'write' ? false : true);
                $calendars[] = $calendar; 

            }

        }
        return $calendars;

    }

    /**
     * Returns a single calendar by it's url 
     * 
     * @param string $url 
     * @return Atmail_Calendar_Calendar 
     */
    public function getCalendarByUrl($url) {

        $result = $this->_dav->propfind($url, array(
            '{DAV:}resourcetype',
            '{DAV:}displayname',
            self::PROP_CTAG,
            self::PROP_DESCRIPTION,
            self::PROP_ORDER,
            self::PROP_COLOR,
        ), 0);
		
        $calendar = $this->convertPropertiesToCalendar($url, $result);
        return $calendar;

    }

    /**
     * Updates a calendars' properties.
     *
     * @param Atmail_Calendar_Calendar $calendar
     * @return void
     */
    public function updateCalendar(Atmail_Calendar_Calendar $calendar) {

        // Fetch the old calendar
        $oldCalendar = $this->getCalendarByUrl($calendar->url);
    
        // The list of properties we support for updating 
        $propertyMap = array(
            '{DAV:}displayname' => 'displayName',
            self::PROP_DESCRIPTION => 'description',
            self::PROP_ORDER => 'order',
            self::PROP_COLOR => 'color', 
        );

        foreach($propertyMap as $davProp => $objProp) { 

            // if the values are the same, no need to update
            if ($oldCalendar->$objProp == $calendar->$objProp)
                continue;

            // We need to update properties one-by-one, because of one fails we 
            // still want to try updating the other properties
            $this->_dav->propPatch($calendar->url, array(
                $davProp => $calendar->$objProp
            ));

        }

    }

    /**
     * Creates a new calendar 
     * 
     * @param Atmail_Calendar_Calendar $calendar 
     * @return void
     */
    public function createCalendar(Atmail_Calendar_Calendar $calendar) {

        if (!$calendar->url) {
            $calendarHomeSet = $this->getCalendarHomeSet();
            $calendar->url = rtrim($calendarHomeSet[0],'/') . '/' . Sabre_DAV_UUIDUtil::getUUID();
        }

        $body = '<?xml version="1.0"?>' . "\n";
        $body.= '<c:mkcalendar xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:ic="http://apple.com/ns/ical/">' . "\n";
        $body.= "  <d:set><d:prop>\n";
        $body.= "    <d:displayname>" . htmlspecialchars($calendar->displayName, ENT_NOQUOTES, 'UTF-8') . "</d:displayname>\n";
        $body.= "    <c:supported-calendar-component-set><c:comp name=\"VEVENT\" /><c:comp name=\"VTODO\" /></c:supported-calendar-component-set>\n";
        if ($calendar->description) {
            $body.= "    <c:calendar-description>" . htmlspecialchars($calendar->description, ENT_NOQUOTES, 'UTF-8') . "</c:calendar-description>\n";
        }
        if ($calendar->color) {
            $body.= "    <ic:calendar-color>" . htmlspecialchars($calendar->color, ENT_NOQUOTES, 'UTF-8') . "</ic:calendar-color>\n";
        }
        if ($calendar->order) {
            $body.= "    <ic:calendar-order>" . htmlspecialchars($calendar->order, ENT_NOQUOTES, 'UTF-8') . "</ic:calendar-order>\n";
        }
        $body.= "  </d:prop></d:set>\n";
        $body.= '</c:mkcalendar>';
        
        $headers = array(
            'Content-Type' => 'application/xml',
        );

        $result = $this->_dav->request('MKCALENDAR', $calendar->url, $body, $headers);

    }

    /**
     * Deletes an entire calendar 
     * 
     * @param Atmail_Calendar_Calendar $calendar 
     * @return void
     */
    public function deleteCalendar(Atmail_Calendar_Calendar $calendar)
	{
		$dbAdapter = Zend_Registry::get('dbAdapter');

		 $result = $dbAdapter->query(
            "SELECT id FROM calendars
            WHERE calendars.uri = ? AND calendars.principaluri = ?",
            array(basename($calendar->url), 'principals/users/' . $this->_userName)
        );
        $result = $result->fetchAll();
        if (!$result)
        {
            throw new Atmail_Calendar_Exception('Could not find this atmail calendar in the database');
        }
        $calendarId = $result[0]['id'];

		$query = "DELETE FROM calendarDelegates WHERE calendarid = ?";
        $dbAdapter->query($query, array($calendarId));
        $this->_dav->request('DELETE', $calendar->url);

    }

    /**
     * Returns true if the external server could be reached.
     * 
     * @return bool 
     */
    public function ping() {

        $options = $this->_dav->options();
        return in_array('calendar-access', $options); 
        
    }

    /**
     * Returns a list of events by a date range
     *
     * The dates must be specified as unix timestamps 
     *
     * @param Atmail_Calendar_Calendar $calendar 
     * @param int $startDate 
     * @param int $endDate 
     * @return Atmail_Calendar_Event[] 
     */
    public function getEventsByDateRange(Atmail_Calendar_Calendar $calendar, $startDate, $endDate) {

        if (!is_int($startDate) || !is_int($endDate)) {
            throw new InvalidArgumentException('Both the start and enddate must be specified as an integer');
        }

        $icalStart = gmdate('Ymd\\THis\\Z', $startDate);
        $icalEnd = gmdate('Ymd\\THis\\Z', $endDate);

        $body = '<?xml version="1.0"?>' . "\n";
        $body.= '<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">' . "\n";
        $body.= "  <d:prop>\n";
        $body.= "    <d:getetag />\n";
        $body.= "    <c:calendar-data />\n";
        $body.= "  </d:prop>\n";
        $body.= "  <c:filter>\n";
        $body.= "    <c:comp-filter name=\"VCALENDAR\">\n";
        $body.= "      <c:comp-filter name=\"VEVENT\">\n";
        $body.= "        <c:time-range start=\"$icalStart\" end=\"$icalEnd\" />\n";
        $body.= "      </c:comp-filter>\n";
        $body.= "    </c:comp-filter>\n";
        $body.= "  </c:filter>\n";
        $body.= "</c:calendar-query>";

        return $this->calendarQuery($calendar, $body);

    }

    /**
     * Returns a list of events with alarms triggering within the specified 
     * date range.
     *
     * The dates must be specified as unix timestamps
     * 
     * @param Atmail_Calendar_Calendar $calendar 
     * @param int $startDate 
     * @param int $endDate 
     * @return Atmail_Calendar_Event[] 
     */
    public function getAlarmsByDateRange(Atmail_Calendar_Calendar $calendar, $startDate, $endDate) {

        if (!is_int($startDate) || !is_int($endDate)) {
            throw new InvalidArgumentException('Both the start and enddate must be specified as an integer');
        }

        $icalStart = gmdate('Ymd\\THis\\Z', $startDate);
        $icalEnd = gmdate('Ymd\\THis\\Z', $endDate);

        $body = '<?xml version="1.0"?>' . "\n";
        $body.= '<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">' . "\n";
        $body.= "  <d:prop>\n";
        $body.= "    <d:getetag />\n";
        $body.= "    <c:calendar-data />\n";
        $body.= "  </d:prop>\n";
        $body.= "  <c:filter>\n";
        $body.= "    <c:comp-filter name=\"VCALENDAR\">\n";
        $body.= "      <c:comp-filter name=\"VEVENT\">\n";
        $body.= "        <c:comp-filter name=\"VALARM\">\n";
        $body.= "          <c:time-range start=\"$icalStart\" end=\"$icalEnd\" />\n";
        $body.= "        </c:comp-filter>\n";
        $body.= "      </c:comp-filter>\n";
        $body.= "    </c:comp-filter>\n";
        $body.= "  </c:filter>\n";
        $body.= "</c:calendar-query>";

        return $this->calendarQuery($calendar, $body);

    }

    /**
     * Creates a new event in a calendar
     *
     * @param Atmail_Calendar_Calendar $calendar 
     * @param Atmail_Calendar_Event $event 
     * @return void
     */
    public function createEvent(Atmail_Calendar_Calendar $calendar, Atmail_Calendar_Event $event) {

        if (!$event->url) {
            $event->url = $calendar->url . '/' . Sabre_DAV_UUIDUtil::getUUID() . '.ics';
        }

        $this->_dav->request('PUT', $event->url, $event->icalendarData,array(

            // Ensuring we're not overwriting any existing items
            'If-None-Match' => '*',
        ));

        // Making sure we update the etag, if the information is available.
        $event->etag = isset($response['headers']['etag'])?$response['headers']['etag']:null;
        Atmail_Calendar_Adapter_CalDAV::$forceRefresh = true;
    }

    /**
     * Updates an existing event in a calendar
     *
     * If the oldETag argument is specified, we will instruct the server to 
     * only update the event the etag has not changed.
     *
     * @param Atmail_Calendar_Calendar $calendar 
     * @param Atmail_Calendar_Event $event
     * @param string $oldETag  
     * @return void
     */
    public function updateEvent(Atmail_Calendar_Calendar $calendar, Atmail_Calendar_Event $event) {

        if (!$event->url)
		{
            throw new InvalidArgumentException('Event did not have a url property.');
        }

        $response = $this->_dav->request('PUT', $event->url, $event->icalendarData, array());

        // Making sure we update the etag, if the information is available.
        $event->etag = isset($response['headers']['etag'])?$response['headers']['etag']:null;
        Atmail_Calendar_Adapter_CalDAV::$forceRefresh = true;
    }

    /**
     * Returns a single event, based on it's url. 
     * 
     * @param string $url 
     * @return Atmail_Calendar_Event 
     */
    public function getEventByUrl($url) {
        
        $result = $this->_dav->request('GET', $url);
        $event = new Atmail_Calendar_Event();
        $event->url = $url;
        $event->etag = isset($result['headers']['etag'])?$result['headers']['etag']:null;
        $event->icalendarData = $result['body'];
        
        return $event;

    }

    /**
     * Returns a single event, based on it's uid.
     *
     * Note that this is likely considerably slower than getEventByUrl 
     * 
     * @param Atmail_Calendar_Calendar $calendar
     * @param string $url
     * @return Atmail_Calendar_Event 
     */
    public function getEventByUid(Atmail_Calendar_Calendar $calendar, $uid) {

        $body = '<?xml version="1.0"?>' . "\n";
        $body.= '<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">' . "\n";
        $body.= "  <d:prop>\n";
        $body.= "    <d:getetag />\n";
        $body.= "    <c:calendar-data />\n";
        $body.= "  </d:prop>\n";
        $body.= "  <c:filter>\n";
        $body.= "    <c:comp-filter name=\"VCALENDAR\">\n";
        $body.= "      <c:comp-filter name=\"VEVENT\">\n";
        $body.= "        <c:prop-filter name=\"UID\">\n";
        $body.= "          <c:text-match collation=\"i;octet\">" . $uid . "</c:text-match>\n";
        $body.= "        </c:prop-filter>\n";
        $body.= "      </c:comp-filter>\n";
        $body.= "    </c:comp-filter>\n";
        $body.= "  </c:filter>\n";
        $body.= "</c:calendar-query>";

        $result = $this->calendarQuery($calendar, $body);

        if (!count($result))
            throw new Atmail_Calendar_Exception('Event with uid ' . $uid . ' not found');

        return current($result);


    }
    /**
     * Deletes an event
     * 
     * @param Atmail_Calendar_Event $Atmail_Calendar_Event 
     * @return void
     */
    public function deleteEvent(Atmail_Calendar_Event $event) {

        $this->_dav->request('DELETE', $event->url);
        Atmail_Calendar_Adapter_CalDAV::$forceRefresh = true;
    }

    /**
     * Returns the list of calendar homes for the current user. Usually this is 
     * just one url, but it may be multiple
     * 
     * @return array 
     */
    public function getCalendarHomeSet() {

        $this->fetchAccountUrls();
        return $this->_calendarHomeSet;

    }

    /**
     * Returns the principal url for the current user. 
     * 
     * @return string 
     */
    public function getPrincipalUrl() {

        $this->fetchAccountUrls();
        return $this->_principalUrl;

    }

    /**
     * This method is responsible for fetching both the calendarHomeSet and 
     * principalUrl information. It will use the cache if it's available.
     * 
     * @return void
     */
    protected function fetchAccountUrls() {

        if ($this->_calendarHomeSet && $this->_principalUrl) {
            return;
        }

        $cacheId = $this->generateCacheId('accountUrls');

        Atmail_Cache::setUsername($this->_userName);
        if ($result = Atmail_Cache::fetch(Atmail_Cache::TYPE_DAV, $cacheId)) {
            $result = unserialize($result);
            $this->_principalUrl = $result[0];
            $this->_calendarHomeSet = $result[1];
            return;
        }

        // Didn't have it cached, lets fetch a fresh version 
        $result = $this->_dav->propfind('',array(
            '{DAV:}current-user-principal',
        ));
        
        if (!isset($result['{DAV:}current-user-principal'])) {
            throw new Atmail_Calendar_Exception('The server did not return a valid current-user-principal property');
        }
        
        $this->_principalUrl = $result['{DAV:}current-user-principal']->getHref();

        // Fetching the calendarHomeSet
        $homeSetProp = '{' . Sabre_CalDAV_Plugin::NS_CALDAV . '}calendar-home-set';

        $result = $this->_dav->propfind($this->_principalUrl,array(
            $homeSetProp
        ));

        if (!isset($result[$homeSetProp])) {
            throw new Atmail_Calendar_Exception('The server did not return a valid calendar-home-set property');
        }

        $this->_calendarHomeSet = $result[$homeSetProp]->getHrefs();

		// Caching the result
		Atmail_Cache::store(Atmail_Cache::TYPE_DAV, $cacheId, serialize(array($this->_principalUrl, $this->_calendarHomeSet)));
    }

    /**
     * Convert DAV properties to a Calendar object
     *
     * @param string $url The url 
     * @param array $properties
     * @return Atmail_Calendar_Calendar 
     */
    protected function convertPropertiesToCalendar($url, $properties) {

        if (
            !isset($properties['{DAV:}resourcetype']) ||
            !$properties['{DAV:}resourcetype'] instanceof Sabre_DAV_Property_ResourceType || 
            !$properties['{DAV:}resourcetype']->is('{' . Sabre_CalDAV_Plugin::NS_CALDAV . '}calendar')
        ) {
            throw new Atmail_Calendar_Exception('Requested resource does not have the \'calendar\' resourcetype');
        }
        $homeCalendar = $this->getCalendarHomeSet();
        $homeCalendar = $homeCalendar[0] . 'calendar';

        $calendar = new Atmail_Calendar_Calendar();
        $calendar->url = $url;
        $calendar->displayName = isset($properties['{DAV:}displayname'])?$properties['{DAV:}displayname']:null;
        $calendar->ctag = isset($properties[self::PROP_CTAG])?$properties[self::PROP_CTAG]:null;
        $calendar->description = isset($properties[self::PROP_DESCRIPTION])?$properties[self::PROP_DESCRIPTION]:null;
        $calendar->readOnly = false;
        $calendar->isShared = false;
        $calendar->isHome = trim(urldecode($homeCalendar), '/') == trim(urldecode($url), '/');
        $calendar->order = isset($properties[self::PROP_ORDER])?$properties[self::PROP_ORDER]:null;
        $calendar->color = isset($properties[self::PROP_COLOR])?$properties[self::PROP_COLOR]:null;
        $calendar->principalUrl = isset($properties[self::PROP_OWNER])?$properties[self::PROP_OWNER]:$this->getPrincipalUrl();

        // lets try and extract the owner from the principal url
        $calendar->owner = trim(substr($calendar->principalUrl, strrpos($calendar->principalUrl, '/')), '/');

        return $calendar;
    }

    /**
     * Peforms a calendar query request, and performs all the needed 
     * transformations to return a list of Atmail_Calendar_Event objects.
     * 
     * @param Atmail_Calendar_Calendar $calendar 
     * @param srting $body 
     * @return array 
     */
    protected function calendarQuery(Atmail_Calendar_Calendar $calendar, $body) {

        // Checking the cache *if* we have a ctag
        $cacheId = null; 
        if ($calendar->ctag && !Atmail_Calendar_Adapter_CalDAV::$forceRefresh) {
            Atmail_Calendar_Adapter_CalDAV::$forceRefresh = false;
            $cacheId = $this->generateCacheId('calendarQuery:' . urldecode($calendar->url) . ':' . $calendar->ctag . ':' . md5($body));
            Atmail_Cache::setUsername($this->_userName);
            if ($result = Atmail_Cache::fetch(Atmail_Cache::TYPE_DAV, $cacheId)) {
                return unserialize($result);
            }
        } 

        $headers = array(
            'Content-Type' => 'application/xml',
            'Depth'        => '1',
        );

        $result = $this->_dav->request('REPORT', $calendar->url, $body, $headers);

        $multiStatus = $this->_dav->parseMultiStatus($result['body']);

        $events = array();
        foreach($multiStatus as $uri=>$propLists) {

            if(!isset($propLists[200])) {
                throw new Atmail_Calendar_Exception('We did not receive properties marked 200 Ok from the calendar-query REPORT');
            }
            $event = new Atmail_Calendar_Event();
            $event->url = $uri;
            $event->icalendarData = $propLists[200][self::PROP_CALENDARDATA];
            if (isset($propLists[200]['getetag'])) {
                $event->etag = $propLists[200]['getetag'];
            }

            $events[] = $event;

        }
        
        if($cacheId)
		{
            Atmail_Cache::store(Atmail_Cache::TYPE_DAV, $cacheId, serialize($events));
        }
        return $events;

    }

    /**
     * Returns a list of calendar homes for principals principals the current
     * user has access to through the calendar-proxy functionality.
     * 
     * @return array 
     */
    private function getProxiedPrincipals() {

        $body = <<<XML
<?xml version="1.0"?>
<d:expand-property xmlns:d="DAV:">
    <d:property name="calendar-proxy-read-for" namespace="http://calendarserver.org/ns/">
        <d:property name="calendar-home-set" namespace="urn:ietf:params:xml:ns:caldav" />
    </d:property>
    <d:property name="calendar-proxy-write-for" namespace="http://calendarserver.org/ns/">
        <d:property name="calendar-home-set" namespace="urn:ietf:params:xml:ns:caldav" />
    </d:property>
</d:expand-property>
XML;

        $result = $this->_dav->request('REPORT',$this->getPrincipalUrl(), $body);

        $dom = Sabre_DAV_XMLUtil::loadDOMDocument($result['body']);
        $xp = new DOMXPath($dom);

        $xp->registerNamespace('c', 'urn:ietf:params:xml:ns:caldav');
        $xp->registerNamespace('cs', 'http://calendarserver.org/ns/');
        $xp->registerNamespace('d', 'urn:dav');
        $values = array('read' => array(), 'write' => array());
        $result = $xp->query("/d:multistatus/d:response/d:propstat/d:prop/cs:calendar-proxy-read-for/d:response/d:propstat/d:prop/c:calendar-home-set/d:href");
        foreach($result as $elem)
        {
            $values['read'][] = $elem->nodeValue;
        }
        $result = $xp->query("/d:multistatus/d:response/d:propstat/d:prop/cs:calendar-proxy-write-for/d:response/d:propstat/d:prop/c:calendar-home-set/d:href");
        foreach($result as $elem)
        {
            $values['write'][] = $elem->nodeValue;
        }

        return $values;
    }
}
