<?php
/**
 * Horde_ActiveSync_Message_Appointment
 *
 * Portions of this class were ported from the Z-Push project:
 *   File      :   wbxml.php
 *   Project   :   Z-Push
 *   Descr     :   WBXML mapping file
 *
 *   Created   :   01.10.2007
 *
 *   � Zarafa Deutschland GmbH, www.zarafaserver.de
 *   This file is distributed under GPL-2.0.
 *   Consult COPYING file for details
 *
 * @license   http://www.horde.org/licenses/gpl GPLv2
 *
 * @copyright 2009-2020 Horde LLC (http://www.horde.org)
 * @author    Michael J Rubinsky <mrubinsk@horde.org>
 * @package   ActiveSync
 */
/**
 * Horde_ActiveSync_Message_Appointment
 *
 * @license   http://www.horde.org/licenses/gpl GPLv2
 *
 * @copyright 2009-2020 Horde LLC (http://www.horde.org)
 * @author    Michael J Rubinsky <mrubinsk@horde.org>
 * @package   ActiveSync
 */
class Horde_ActiveSync_Message_Appointment extends Horde_ActiveSync_Message_Base
{
    /* POOMCAL Tag Constants */
    const POOMCAL_TIMEZONE           = 'POOMCAL:Timezone';
    const POOMCAL_ALLDAYEVENT        = 'POOMCAL:AllDayEvent';
    const POOMCAL_ATTENDEES          = 'POOMCAL:Attendees';
    const POOMCAL_ATTENDEE           = 'POOMCAL:Attendee';
    const POOMCAL_ATTENDEESTATUS     = 'POOMCAL:AttendeeStatus';
    const POOMCAL_ATTENDEETYPE       = 'POOMCAL:AttendeeType';
    const POOMCAL_EMAIL              = 'POOMCAL:Email';
    const POOMCAL_NAME               = 'POOMCAL:Name';
    const POOMCAL_BODY               = 'POOMCAL:Body';
    const POOMCAL_BODYTRUNCATED      = 'POOMCAL:BodyTruncated';
    const POOMCAL_BUSYSTATUS         = 'POOMCAL:BusyStatus';
    const POOMCAL_CATEGORIES         = 'POOMCAL:Categories';
    const POOMCAL_CATEGORY           = 'POOMCAL:Category';
    const POOMCAL_RTF                = 'POOMCAL:Rtf';
    const POOMCAL_DTSTAMP            = 'POOMCAL:DtStamp';
    const POOMCAL_ENDTIME            = 'POOMCAL:EndTime';
    const POOMCAL_EXCEPTION          = 'POOMCAL:Exception';
    const POOMCAL_EXCEPTIONS         = 'POOMCAL:Exceptions';
    const POOMCAL_DELETED            = 'POOMCAL:Deleted';
    const POOMCAL_EXCEPTIONSTARTTIME = 'POOMCAL:ExceptionStartTime';
    const POOMCAL_LOCATION           = 'POOMCAL:Location';
    const POOMCAL_MEETINGSTATUS      = 'POOMCAL:MeetingStatus';
    const POOMCAL_ORGANIZEREMAIL     = 'POOMCAL:OrganizerEmail';
    const POOMCAL_ORGANIZERNAME      = 'POOMCAL:OrganizerName';
    const POOMCAL_RECURRENCE         = 'POOMCAL:Recurrence';
    const POOMCAL_TYPE               = 'POOMCAL:Type';
    const POOMCAL_UNTIL              = 'POOMCAL:Until';
    const POOMCAL_OCCURRENCES        = 'POOMCAL:Occurrences';
    const POOMCAL_INTERVAL           = 'POOMCAL:Interval';
    const POOMCAL_DAYOFWEEK          = 'POOMCAL:DayOfWeek';
    const POOMCAL_DAYOFMONTH         = 'POOMCAL:DayOfMonth';
    const POOMCAL_WEEKOFMONTH        = 'POOMCAL:WeekOfMonth';
    const POOMCAL_MONTHOFYEAR        = 'POOMCAL:MonthOfYear';
    const POOMCAL_REMINDER           = 'POOMCAL:Reminder';
    const POOMCAL_SENSITIVITY        = 'POOMCAL:Sensitivity';
    const POOMCAL_SUBJECT            = 'POOMCAL:Subject';
    const POOMCAL_STARTTIME          = 'POOMCAL:StartTime';
    const POOMCAL_UID                = 'POOMCAL:UID';

    // 14.0
    const POOMCAL_DISALLOWNEWTIMEPROPOSAL = 'POOMCAL:DisallowNewTimeProposal';
    const POOMCAL_RESPONSEREQUESTED       = 'POOMCAL:ResponseRequested';
    const POOMCAL_APPOINTMENTREPLYTIME    = 'POOMCAL:AppointmentReplyTime';
    const POOMCAL_CALENDARTYPE            = 'POOMCAL:CalendarType';
    const POOMCAL_ISLEAPMONTH             = 'POOMCAL:IsLeapMonth';
    const POOMCAL_RESPONSETYPE            = 'POOMCAL:ResponseType';

    // 14.1
    const POOMCAL_FIRSTDAYOFWEEK          = 'POOMCAL:FirstDayOfWeek';
    const POOMCAL_ONLINECONFLINK          = 'POOMCAL:OnlineMeetingConfLink';
    const POOMCAL_ONLINEEXTLINK           = 'POOMCAL:OnlineMeetingExternalLink';

    // 16.0
    const POOMCAL_CLIENTUID               = 'POOMCAL:ClientUid';

    /* Sensitivity */
    const SENSITIVITY_NORMAL         = 0;
    const SENSITIVITY_PERSONAL       = 1;
    const SENSITIVITY_PRIVATE        = 2;
    const SENSITIVITY_CONFIDENTIAL   = 3;

    /* Busy status */
    const BUSYSTATUS_FREE            = 0;
    const BUSYSTATUS_TENTATIVE       = 1;
    const BUSYSTATUS_BUSY            = 2;
    const BUSYSTATUS_OUT             = 3;
    // 16.0 only.
    const BUSYSTATUS_ELSEWHERE       = 4;

    /* All day meeting */
    const IS_ALL_DAY                 = 1;

    /* Meeting status */
    const MEETING_NOT_MEETING        = 0;
    const MEETING_IS_MEETING         = 1;
    const MEETING_RECEIVED           = 3;
    const MEETING_CANCELLED          = 5;
    const MEETING_CANCELLED_RECEIVED = 7;

    /* Response status */
    const RESPONSE_NONE              = 0;
    const RESPONSE_ORGANIZER         = 1;
    const RESPONSE_TENTATIVE         = 2;
    const RESPONSE_ACCEPTED          = 3;
    const RESPONSE_DECLINED          = 4;
    const RESPONSE_NORESPONSE        = 5;

    /**
     * DOW mapping for DATE to MASK
     *
     * @var array
     */
    protected $_dayOfWeekMap = array(
        Horde_Date::DATE_SUNDAY    => Horde_Date::MASK_SUNDAY,
        Horde_Date::DATE_MONDAY    => Horde_Date::MASK_MONDAY,
        Horde_Date::DATE_TUESDAY   => Horde_Date::MASK_TUESDAY,
        Horde_Date::DATE_WEDNESDAY => Horde_Date::MASK_WEDNESDAY,
        Horde_Date::DATE_THURSDAY  => Horde_Date::MASK_THURSDAY,
        Horde_Date::DATE_FRIDAY    => Horde_Date::MASK_FRIDAY,
        Horde_Date::DATE_SATURDAY  => Horde_Date::MASK_SATURDAY,
    );

    /**
     * Property mapping.
     *
     * @var array
     */
    protected $_mapping = array(
        self::POOMCAL_TIMEZONE       => array (self::KEY_ATTRIBUTE => 'timezone'),
        self::POOMCAL_ALLDAYEVENT    => array (self::KEY_ATTRIBUTE => 'alldayevent'),
        self::POOMCAL_BUSYSTATUS     => array (self::KEY_ATTRIBUTE => 'busystatus'),
        self::POOMCAL_ORGANIZERNAME  => array (self::KEY_ATTRIBUTE => 'organizername'),
        self::POOMCAL_ORGANIZEREMAIL => array (self::KEY_ATTRIBUTE => 'organizeremail'),
        self::POOMCAL_DTSTAMP        => array (self::KEY_ATTRIBUTE => 'dtstamp', self::KEY_TYPE => self::TYPE_DATE),
        self::POOMCAL_ENDTIME        => array (self::KEY_ATTRIBUTE => 'endtime', self::KEY_TYPE => self::TYPE_DATE),
        self::POOMCAL_REMINDER       => array (self::KEY_ATTRIBUTE => 'reminder'),
        self::POOMCAL_SENSITIVITY    => array (self::KEY_ATTRIBUTE => 'sensitivity'),
        self::POOMCAL_SUBJECT        => array (self::KEY_ATTRIBUTE => 'subject'),
        self::POOMCAL_STARTTIME      => array (self::KEY_ATTRIBUTE => 'starttime', self::KEY_TYPE => self::TYPE_DATE),
        self::POOMCAL_UID            => array (self::KEY_ATTRIBUTE => 'uid'),
        self::POOMCAL_MEETINGSTATUS  => array (self::KEY_ATTRIBUTE => 'meetingstatus'),
        self::POOMCAL_ATTENDEES      => array (self::KEY_ATTRIBUTE => 'attendees', self::KEY_TYPE => 'Horde_ActiveSync_Message_Attendee', self::KEY_VALUES => self::POOMCAL_ATTENDEE),
        self::POOMCAL_CATEGORIES     => array (self::KEY_ATTRIBUTE => 'categories', self::KEY_VALUES => self::POOMCAL_CATEGORY),
        self::POOMCAL_RECURRENCE     => array (self::KEY_ATTRIBUTE => 'recurrence', self::KEY_TYPE => 'Horde_ActiveSync_Message_Recurrence'),
        self::POOMCAL_EXCEPTIONS     => array (self::KEY_ATTRIBUTE => 'exceptions', self::KEY_TYPE => 'Horde_ActiveSync_Message_Exception', self::KEY_VALUES => self::POOMCAL_EXCEPTION),
    );

    /**
     * Property values.
     *
     * @var array
     */
    protected $_properties = array(
        'alldayevent'    => false,
        'attendees'      => array(),
        'busystatus'     => false,
        'categories'     => array(),
        'dtstamp'        => false,
        'endtime'        => false,
        'exceptions'     => array(),
        'organizeremail' => false,
        'organizername'  => false,
        'meetingstatus'  => false,
        'recurrence'     => false,
        'reminder'       => false,
        'sensitivity'    => false,
        'starttime'      => false,
        'subject'        => false,
        'timezone'       => false,
        'uid'            => false,
        // Not part of the protocol. Used internally.
        'serveruid'     => false,
    );

    /**
     * Const'r
     *
     * @see Horde_ActiveSync_Message_Base::__construct()
     */
    public function __construct(array $options = array())
    {
        parent::__construct($options);

        // Removed in 16.0
        if ($this->_version <= Horde_ActiveSync::VERSION_FOURTEENONE) {
            $this->_mapping += array(
                self::POOMCAL_LOCATION => array(self::KEY_ATTRIBUTE => 'location'),
            );
            $this->_properties += array(
                'location' => false,
            );
        }

        if ($this->_version < Horde_ActiveSync::VERSION_TWELVE) {
            $this->_mapping += array(
                self::POOMCAL_BODY => array(self::KEY_ATTRIBUTE => 'body'),
                self::POOMCAL_BODYTRUNCATED => array(self::KEY_ATTRIBUTE => 'bodytruncated'),
                self::POOMCAL_RTF => array(self::KEY_ATTRIBUTE => 'rtf'),
            );

            $this->_properties += array(
                'body' => false,
                'bodytruncated' => 0,
                'rtf' => false
            );
        } else {
            $this->_mapping += array(
                Horde_ActiveSync::AIRSYNCBASE_BODY => array(self::KEY_ATTRIBUTE => 'airsyncbasebody', self::KEY_TYPE => 'Horde_ActiveSync_Message_AirSyncBaseBody')
            );
            $this->_properties += array(
                'airsyncbasebody' => false
            );

            if ($this->_version >= Horde_ActiveSync::VERSION_FOURTEEN) {
                $this->_mapping += array(
                    self::POOMCAL_RESPONSEREQUESTED => array(self::KEY_ATTRIBUTE => 'responserequested'),
                    self::POOMCAL_APPOINTMENTREPLYTIME => array(self::KEY_ATTRIBUTE => 'appointmentreplytime', self::KEY_TYPE => self::TYPE_DATE_DASHES),
                    self::POOMCAL_RESPONSETYPE => array(self::KEY_ATTRIBUTE => 'responsetype'),
                    self::POOMCAL_DISALLOWNEWTIMEPROPOSAL => array(self::KEY_ATTRIBUTE => 'disallownewtimeproposal'),
                );
                $this->_properties += array(
                    'disallownewtimeproposal' => false,
                    'responserequested' => false,
                    'appointmentreplytime' => false,
                    'responsetype' => false,
                );
            }
            if ($this->_version >= Horde_ActiveSync::VERSION_FOURTEENONE) {
                $this->_mapping += array(
                    self::POOMCAL_ONLINECONFLINK => array(self::KEY_ATTRIBUTE => 'onlinemeetingconflink'),
                    self::POOMCAL_ONLINEEXTLINK  => array(self::KEY_ATTRIBUTE => 'onlinemeetingexternallink')
                );
                $this->_properties += array(
                    'onlinemeetingconflink' => false,
                    'onlinemeetingexternallink' => false
                );
            }
            if ($this->_version >= Horde_ActiveSync::VERSION_SIXTEEN) {
                $this->_mapping += array(
                    Horde_ActiveSync::AIRSYNCBASE_LOCATION => array(self::KEY_ATTRIBUTE => 'location', self::KEY_TYPE => 'Horde_ActiveSync_Message_AirSyncBaseLocation'),
                    self::POOMCAL_CLIENTUID => array(self::KEY_ATTRIBUTE => 'clientuid'),
                    Horde_ActiveSync::AIRSYNCBASE_INSTANCEID => array(self::KEY_ATTRIBUTE => 'instanceid', self::KEY_TYPE => self::TYPE_DATE),
                    Horde_ActiveSync::AIRSYNCBASE_ATTACHMENTS => array(
                        self::KEY_ATTRIBUTE => 'airsyncbaseattachments',
                        self::KEY_TYPE => array('Horde_ActiveSync_Message_AirSyncBaseAttachment', 'Horde_ActiveSync_Message_AirSyncBaseAdd', 'Horde_ActiveSync_Message_AirSyncBaseDelete'),
                        self::KEY_VALUES => array(Horde_ActiveSync::AIRSYNCBASE_ATTACHMENT, Horde_ActiveSync::AIRSYNCBASE_ADD, Horde_ActiveSync::AIRSYNCBASE_DELETE)
                    ),
                );
                $this->_properties += array(
                    'location' => false,
                    'clientuid' => false,
                    'instanceid' => false,
                    'airsyncbaseattachments' => array(),
                );
            }
        }
    }

    /**
     * Give concrete classes the chance to enforce rules on property values.
     *
     * @return boolean  True on success, otherwise false.
     */
    protected function _validateDecodedValues()
    {
        if ($this->commandType == Horde_ActiveSync::SYNC_MODIFY &&
            $this->_version == Horde_ActiveSync::VERSION_SIXTEEN) {

            if ($this->_properties['alldayevent'] == true) {
                // Timezone element is forbidden here.
                if (!empty($this->_properties['timezone'])) {
                    return false;
                }

                // No time components allowed here. The server is to interpret
                // the starttime as occuring on the date listed here regardless
                // of the timezone.
                if ($this->_properties['starttime'] &&
                    ($this->_properties['starttime']->hour != 0 ||
                     $this->_properties['starttime']->min != 0 ||
                     $this->_properties['starttime']->sec != 0)) {
                    return false;
                }
                if ($this->_properties['endtime'] &&
                    ($this->_properties['endtime']->hour != 0 ||
                     $this->_properties['endtime']->min != 0 ||
                     $this->_properties['endtime']->sec != 0)) {
                    return false;
                }
                if ($this->_properties['recurrence'] && $this->_properties['recurrence']->until &&
                    ($this->_properties['recurrence']->until != 0 ||
                     $this->_properties['recurrence']->until != 0 ||
                     $this->_properties['recurrence']->until != 0)) {
                    return false;
                }
            }

            // Exception events must match the alldayevent property of the
            // master event.
            foreach ($this->_properties['exceptions'] as $ex) {
                if ($ex->alldayevent != $this->_properties['alldayevent']) {
                    return false;
                }
            }
        }

        // These values are not allowed in a EAS 16.0 command request.
        // @todo - should we just wipe the values instead of failing the test?
        if ($this->_version == Horde_ActiveSync::VERSION_SIXTEEN) {
            if (!empty($this->_properties['uid']) ||
                !empty($this->_properties['dtstamp']) ||
                !empty($this->_properties['organizername']) ||
                !empty($this->_properties['organizeremail'])) {
                return false;
            }
        }

        return true;
    }

    /**
     * Give concrete classes the chance to enforce rules before encoding
     * messages to send to the client.
     *
     * @return boolean  True if values were valid (or could be made valid).
     *     False if values are unable to be validated.
     * @since  2.31.0
     */
    protected function _preEncodeValidation()
    {
        if ($this->_properties['alldayevent']) {
            if ($this->_properties['starttime']->hour != 0 ||
                 $this->_properties['starttime']->min != 0 ||
                 $this->_properties['starttime']->sec != 0) {
                return false;
            }
            if ($this->_properties['endtime'] &&
                ($this->_properties['endtime']->hour != 0 ||
                 $this->_properties['endtime']->min != 0 ||
                 $this->_properties['endtime']->sec != 0)) {
                return false;
            }

            // For EAS 16, timezone cannot be sent for allday events. The
            // event is interpreted to be on the given date regardless of
            // timezone...as such, we need to manually convert to UTC here
            // and (re)set the date to be sure it matches the desired date.
            if ($this->_version == Horde_ActiveSync::VERSION_SIXTEEN) {
                $this->_properties['timezone'] = false;
                $mday = $this->_properties['starttime']->mday;
                $this->_properties['starttime']->setTimezone('UTC');
                $this->_properties['starttime']->mday = $mday;
                $this->_properties['starttime']->hour = 0;
                $this->_properties['starttime']->min = 0;
                $this->_properties['starttime']->sec = 0;

                if ($this->_properties['endtime']) {
                    $mday = $this->_properties['endtime']->mday;
                    $this->_properties['endtime']->setTimezone('UTC');
                    $this->_properties['endtime']->mday = $mday;
                    $this->_properties['endtime']->hour = 0;
                    $this->_properties['endtime']->min = 0;
                    $this->_properties['endtime']->sec = 0;
                }
            }
        }

        return true;
    }

    /**
     * Set the timezone
     *
     * @param mixed $date  Either a Horde_Date or timezone descriptor such as
     *                     America/New_York etc...
     *
     * @throws InvalidArgumentException
     */
    public function setTimezone($date)
    {
        if (!($date instanceof Horde_Date)) {
            if (!is_string($date)) {
                throw new InvalidArgumentException(
                    '$date must be an instance of Horde_Date or a valid timezone descriptor');
            }
            $date = new Horde_Date(time(), $date);
        }
        $offsets = Horde_Mapi_Timezone::getOffsetsFromDate($date);
        $tz = Horde_Mapi_Timezone::getSyncTZFromOffsets($offsets);
        $this->_properties['timezone'] = $tz;
    }

    /**
     * Get the event's timezone
     *
     * @return string  The timezone identifier
     */
     public function getTimezone()
     {
         $parser = new Horde_Mapi_Timezone();
         return $parser->getTimezone($this->timezone, date_default_timezone_get());
     }

    /**
     * Set the appointment's modify timestamp
     *
     * @param mixed Horde_Date|integer $date  The date to set.
     * @deprecated
     */
    public function setDTStamp($date)
    {
        if (!($date instanceof Horde_Date)) {
            $date = new Horde_Date($date);
        }
        $this->_properties['dtstamp'] = $date;
    }

    /**
     * Get the appointment's dtimestamp
     *
     * @return Horde_Date  The timestamp.
     * @deprecated
     */
    public function getDTStamp()
    {
        return $this->_getAttribute('dtstamp');
    }

    /**
     * Set the appointment time/duration.
     *
     * @param array $datetime  An array containing:
     *   - start: (Horde_Date) The start time.
     *   - end: (Horde_Date) The end time. If omitted, must include duration or
     *                       allday.
     *   - duration: (integer) The event duration in seconds.
     *   - allday: (boolean) If true, this is an allday event.
     *
     * @throws InvalidArgumentException
     * @deprecated Set individual properties manually from calling code.
     */
    public function setDatetime(array $datetime = array())
    {
        // Start date is always required
        if (empty($datetime['start'])) {
            throw new InvalidArgumentException('Missing the required start parameter');
        }

        /* Get or calculate start and end time in local tz */
        $start = clone($datetime['start']);
        if (!empty($datetime['end'])) {
            $end = clone($datetime['end']);
        } elseif (!empty($datetime['duration'])) {
            $end = clone($start);
            $end->sec += $datetime['duration'];
        } else {
            $end = clone($start);
        }

        // Is this an all day event?
        if ($start->hour == 0 && $start->min == 0 && $start->sec == 0 &&
            $end->hour == 23 && $end->min == 59) {
            // Yes, with end date ending on 23:59, bump up to 00:00 next day.
            $end = new Horde_Date(
                array('year'  => (int)$end->year,
                      'month' => (int)$end->month,
                      'mday'  => (int)$end->mday + 1),
                $end->timezone);
            $this->_properties['alldayevent'] = self::IS_ALL_DAY;
        } elseif ($start->hour == 0 && $start->min == 0 && $start->sec == 0 &&
                  $end->hour == 0 && $end->min == 0 && $end->sec == 0 &&
                  ($end->mday > $start->mday || $end->month > $start->month ||
                   $end->year > $start->year)) {
            // Yes, can use values as-is.
            $this->_properties['alldayevent'] = self::IS_ALL_DAY;
        } elseif (!empty($datetime['allday'])) {
            // Yes, but need to create the end date but must make sure the
            // start time is 00:00.
            $start->hour = $start->min = $start->sec = 0;
            $end = new Horde_Date(
                array('year'  => (int)$end->year,
                      'month' => (int)$end->month,
                      'mday'  => (int)$end->mday + 1),
                $end->timezone);
            $this->_properties['alldayevent'] = self::IS_ALL_DAY;
        }
        $this->_properties['starttime'] = $start;
        $this->_properties['endtime'] = $end;
    }

    /**
     * Get the appointment's time data
     *
     * @return array  An array containing:
     *   - start: (Horde_Date) The start time.
     *   - end: (Horde_Date) The end time.
     *   - allday: (boolean) If true, this is an allday event.
     *  @deprecated
     */
    public function getDatetime()
    {
        $this->_checkDatetime();
        return array(
            'start' => $this->_properties['starttime'],
            'end' => $this->_properties['endtime'],
            'allday' => !empty($this->_properties['alldayevent']) ? true : false
        );
    }

    public function getStarttime()
    {
        $this->_checkDatetime();
        return $this->_properties['starttime'];
    }

    public function getEndtime()
    {
        $this->_checkDatetime();
        return $this->_properties['endtime'];
    }

    /**
     * Checks that the start and end times are populated correctly.
     *
     * @throws  Horde_ActiveSync_Exception if starttime is empty but endtime is
     *          not.
     */
    protected function _checkDatetime()
    {
        // MS-ASCAL 3.2.4.4
        if (empty($this->_properties['starttime'])) {
            if (empty($this->_properties['endtime'])) {
                $now = new Horde_Date(time());
                $now->min = ($now->min > 0 && $now->min < 30) ? 30 : 60;
                $this->_properties['starttime'] = clone $now;
                $now->add(array('min' => 30));
                $this->_properties['endtime'] = clone $now;
            } else {
                throw new Horde_ActiveSync_Exception("Invalid dates");
            }
        } elseif (empty($this->_properties['endtime'])) {
            if ($this->_properties['starttime']->timestamp() > time()) {
                throw new Horde_ActiveSync_Exception("Invalid dates");
            }
            $end = clone $this->_properties['starttime'];
            $end->add(array('min' => 30));
            $this->_properties['endtime'] = $end;
        }
    }

    /**
     * Set the appointment subject field.
     *
     * @param string $subject   A UTF-8 string
     * @deprecated Set the property directly. I.e. $message->subject = 'Test'
     */
    public function setSubject($subject)
    {
        $this->_properties['subject'] = $subject;
    }

    /**
     * Get the subject
     *
     * @return string  The UTF-8 subject string
     * @deprecated Retrieve the value directly. I.e., $message->subject
     */
    public function getSubject()
    {
        return $this->_getAttribute('subject');
    }

    /**
     * Set the appointment uid. Note that this is the client's UID value, and
     * not the value that the server normally uses for the UID. ActiveSync
     * messages do not normally include any server uid value as part of the
     * message directly. This causes issues with meeting requests since most
     * clients will use the CLIENT_ENTRY_ID for this value, and will send the
     * invitation email out using this value as the UID so we sort-of HAVE to
     * use this value as the server's UID.
     *
     * @param string $uid  The server's uid for this appointment
     */
    public function setUid($uid)
    {
        $this->_properties['uid'] = $uid;
    }

    /**
     * Get the client's UID. See not above regarding server UIDs.
     *
     * @return string
     */
    public function getUid()
    {
        return $this->_getAttribute('uid');
    }

    /**
     * Because the client doesn't pass the server uid as part of the message,
     * we need to add it manually so the backend can have access to it
     * when changing this object.
     *
     * @param string $uid  The server UID
     */
    public function setServerUID($uid)
    {
        $this->_properties['serveruid'] = $uid;
    }

    /**
     * Obtain the server UID. See note above.
     *
     * @return string
     */
    public function getServerUID()
    {
        return $this->_getAttribute('serveruid');
    }

    /**
     * Set the organizer name and/or email
     *
     * @param array  'name' and 'email' for this appointment organizer.
     */
    public function setOrganizer(array $organizer)
    {
        $this->_properties['organizername'] = !empty($organizer['name'])
            ? $organizer['name']
            : '';

        $this->_properties['organizeremail'] = !empty($organizer['email'])
            ? $organizer['email']
            : '';
    }

    /**
     * Get the details for the appointment organizer
     *
     * @return array with 'name' and 'email' values
     */
    public function getOrganizer()
    {
        return array(
            'name' => $this->_getAttribute('organizername'),
            'email' => $this->_getAttribute('organizeremail'));
    }

    /**
     * Set appointment location field.
     *
     * @param string $location
     * @deprecated
     */
    public function setLocation($location)
    {
        $this->_properties['location'] = $location;
    }

    /**
     * Get the location field
     *
     * @return string
     * @deprecated
     */
    public function getLocation()
    {
        return $this->_getAttribute('location');
    }

    /**
     * Set recurrence information for this appointment
     *
     * @param Horde_Date_Recurrence $recurrence  The recurrence data.
     * @param integer $fdow                      The first day of the week.
     *        (A Horde_ActiveSync_Message_Recurrence:: constant).  @since 2.4.0
     */
    public function setRecurrence(Horde_Date_Recurrence $recurrence, $fdow = null)
    {
        $r = Horde_ActiveSync::messageFactory('Recurrence');

        if ($this->_version >= Horde_ActiveSync::VERSION_FOURTEENONE) {
            $r->firstdayofweek = $fdow;
        }

        /* Map the type fields */
        switch ($recurrence->recurType) {
        case Horde_Date_Recurrence::RECUR_DAILY:
            $r->type = Horde_ActiveSync_Message_Recurrence::TYPE_DAILY;
            break;
        case Horde_Date_Recurrence::RECUR_WEEKLY:
            $r->type = Horde_ActiveSync_Message_Recurrence::TYPE_WEEKLY;
            $r->dayofweek = $recurrence->getRecurOnDays();
            break;
        case Horde_Date_Recurrence::RECUR_MONTHLY_DATE:
            $r->type = Horde_ActiveSync_Message_Recurrence::TYPE_MONTHLY;
            break;
        case Horde_Date_Recurrence::RECUR_MONTHLY_WEEKDAY;
            $r->type = Horde_ActiveSync_Message_Recurrence::TYPE_MONTHLY_NTH;
            $r->weekofmonth = ceil($recurrence->start->mday / 7);
            $r->dayofweek = $this->_dayOfWeekMap[$recurrence->start->dayOfWeek()];
            break;
        case Horde_Date_Recurrence::RECUR_YEARLY_DATE:
            $r->type = Horde_ActiveSync_Message_Recurrence::TYPE_YEARLY;
            $r->monthofyear = $recurrence->start->month;
            $r->dayofmonth = $recurrence->start->mday;
            break;
        case Horde_Date_Recurrence::RECUR_YEARLY_DAY:
            $r->type = Horde_ActiveSync_Message_Recurrence::TYPE_YEARLYNTH;
            $r->weekofmonth = ceil($recurrence->start->mday / 7);
            $r->monthofyear = $recurrence->start->month;
            break;
        case Horde_Date_Recurrence::RECUR_YEARLY_WEEKDAY:
            $r->type = Horde_ActiveSync_Message_Recurrence::TYPE_YEARLYNTH;
            $r->dayofweek = $this->_dayOfWeekMap[$recurrence->start->dayOfWeek()];
            $r->weekofmonth = ceil($recurrence->start->mday / 7);
            $r->monthofyear = $recurrence->start->month;
            break;
        }
        if (!empty($recurrence->recurInterval)) {
            $r->interval = $recurrence->recurInterval;
        }

        /* AS messages can only have one or the other (or none), not both */
        if ($recurrence->hasRecurCount()) {
            $r->occurrences = $recurrence->getRecurCount();
        } elseif ($recurrence->hasRecurEnd()) {
            $r->until = $recurrence->getRecurEnd();
        }

        // We don't support non-gregorian calendars.
        if ($this->_version >= Horde_ActiveSync::VERSION_FOURTEEN) {
            $r->calendartype = Horde_ActiveSync_Message_Recurrence::CALENDAR_TYPE_GREGORIAN;
        }

        $this->_properties['recurrence'] = $r;
    }

    /**
     * Obtain a recurrence object. Note this returns a Horde_Date_Recurrence
     * object, not Horde_ActiveSync_Message_Recurrence.
     *
     * @return Horde_Date_Recurrence
     */
    public function getRecurrence()
    {
        if (!$recurrence = $this->_getAttribute('recurrence')) {
            return false;
        }

        $d = clone($this->_getAttribute('starttime'));
        $d->setTimezone($this->getTimezone());

        $rrule = new Horde_Date_Recurrence($d);

        /* Map MS AS type field to Horde_Date_Recurrence types */
        switch ($recurrence->type) {
        case Horde_ActiveSync_Message_Recurrence::TYPE_DAILY:
            $rrule->setRecurType(Horde_Date_Recurrence::RECUR_DAILY);
             break;
        case Horde_ActiveSync_Message_Recurrence::TYPE_WEEKLY:
            $rrule->setRecurType(Horde_Date_Recurrence::RECUR_WEEKLY);
            $rrule->setRecurOnDay($recurrence->dayofweek);
            break;
        case Horde_ActiveSync_Message_Recurrence::TYPE_MONTHLY:
            $rrule->setRecurType(Horde_Date_Recurrence::RECUR_MONTHLY_DATE);
            break;
        case Horde_ActiveSync_Message_Recurrence::TYPE_MONTHLY_NTH:
            $rrule->setRecurType(Horde_Date_Recurrence::RECUR_MONTHLY_WEEKDAY);
            $rrule->setRecurOnDay($recurrence->dayofweek);
            break;
        case Horde_ActiveSync_Message_Recurrence::TYPE_YEARLY:
            $rrule->setRecurType(Horde_Date_Recurrence::RECUR_YEARLY_DATE);
            break;
        case Horde_ActiveSync_Message_Recurrence::TYPE_YEARLYNTH:
            $rrule->setRecurType(Horde_Date_Recurrence::RECUR_YEARLY_WEEKDAY);
            $rrule->setRecurOnDay($recurrence->dayofweek);
            break;
        }

        if ($rcnt = $recurrence->occurrences) {
            $rrule->setRecurCount($rcnt);
        }
        if ($runtil = $recurrence->until) {
            $rrule->setRecurEnd(new Horde_Date($runtil));
        }
        if ($interval = $recurrence->interval) {
            $rrule->setRecurInterval($interval);
        }

        return $rrule;
    }

    /**
     * Add a recurrence exception
     *
     * @param Horde_ActiveSync_Message_Exception $exception
     */
    public function addException(Horde_ActiveSync_Message_Exception $exception)
    {
        $this->_properties['exceptions'][] = $exception;
    }

    /**
     * Return the exceptions for this appointment.
     *
     * @return array  An array of Horde_ActiveSync_Message_Exception objects
     */
    public function getExceptions()
    {
        foreach ($this->_properties['exceptions'] as $ex) {
            $ex->setParentAppointment($this);
        }

        return $this->_properties['exceptions'];
    }

    /**
     * Set the sensitivity level for this appointment.
     *
     * Should be one of:
     *   normal, personal, private, confidential
     *
     * @param integer $sensitivity  The SENSITIVITY constant
     * @deprecated
     */
    public function setSensitivity($sensitivity)
    {
        $this->_properties['sensitivity'] = $sensitivity;
    }

    /**
     * Return the sensitivity setting for this appointment
     *
     * @return integer  The SENSITIVITY constant
     * @deprecated
     */
    public function getSensitivity()
    {
        return $this->_getAttribute('sensitivity');
    }

    /**
     * Sets the busy status for this appointment
     *
     * @param integer  $busy  The BUSYSTATUS constant
     * @deprecated
     */
    public function setBusyStatus($busy)
    {
        $this->_properties['busystatus'] = $busy;
    }

    /**
     * Return the busy status for this appointment.
     *
     * @return integer The BUSYSTATUS constant
     * @deprecated
     */
    public function getBusyStatus()
    {
        return $this->_getAttribute('busystatus');
    }

    /**
     * Set user response type. Should be one of:
     *   none, organizer, tentative, accepted, declined
     *
     * @param integer $response  The response type constant
     * @deprecated
     */
    public function setResponseType($response)
    {
        $this->_properties['responsetype'] = $response;
    }

    /**
     * Get response type
     *
     * @return integer  The responsetype constant
     * @deprecated
     */
    public function getResponseType()
    {
        return $this->_getAttribute('responsetype');
    }

    /**
     * Set reminder for this appointment.
     *
     * @param integer $minutes  The number of minutes before appintment to
     *                          trigger a reminder.
     * @deprecated
     */
    public function setReminder($minutes)
    {
        $this->_properties['reminder'] = (int)$minutes;
    }

    /**
     * Get the reminder time.
     *
     * @return integer|boolean  Number of minutes before appointment for
     *                          notifications or false if not set.
     * @deprecated
     */
    public function getReminder()
    {
        $reminder = $this->_getAttribute('reminder');
        if ($reminder < 0) {
            return false;
        }
        return $reminder;
    }

    /**
     * Set the status for this appointment. Should be one of:
     *   none, meeting, received, canceled, canceledreceived.
     *
     * @param integer $status  A MEETING_* constant
     */
    public function setMeetingStatus($status)
    {

        $this->_properties['meetingstatus'] = $status;
    }

    /**
     * Return the meeting status for this meeting.
     *
     * @return integer A MEETING_* constant
     */
    public function getMeetingStatus()
    {
        return $this->_getAttribute('meetingstatus');
    }

    /**
     * Add an attendee to this appointment
     *
     * @param array $attendee   'name', 'email' for each attendee
     */
    public function addAttendee($attendee)
    {
        /* Both email and name are REQUIRED if setting an attendee */
        $this->_properties['attendees'][] = $attendee;
    }

    /**
     * Get a list of this event's attendees
     *
     * @return array  An array of 'name' and 'email' hashes
     */
    public function getAttendees()
    {
        return $this->_properties['attendees'];
    }

    /**
     * Set the appointment's body
     *
     * @param string $body  UTF-8 encoded string
     * @deprecated
     */
    public function setBody($body)
    {
        $this->_properties['body'] = $body;
    }

    /**
     * Get the appointment's body
     *
     * @return string  UTF-8 encoded string
     * @deprecated
     */
    public function getBody()
    {
        return $this->_getAttribute('body');
    }

    /**
     * Add a category to the appointment
     *
     * @param string $category
     */
    public function addCategory($category)
    {
        $this->_properties['categories'][] = $category;
    }

    /**
     * Return this appointments tags/categories.
     *
     * @return array
     */
    public function getCategories()
    {
        return $this->_properties['categories'];
    }

    public function addAttachment(Horde_ActiveSync_Message_AirSyncBaseAttachment $atc)
    {
        if ($this->_version >= Horde_ActiveSync::VERSION_SIXTEEN) {
            $this->_properties['airsyncbaseattachments'][] = $atc;
        } else {
            throw new Horde_ActiveSync_Exception('Property unavailable');
        }
    }

    /**
     * Override parent class' method. In EAS 16.0, top level appointment
     * properties are ALWAYS ghosted if they are not explicitly sent.
     *
     * @param string $property  The property to check
     *
     * @return boolean
     */
    public function isGhosted($property)
    {
        if ($this->_version >= Horde_ActiveSync::VERSION_SIXTEEN &&
            empty($this->_exists[$property])) {
            return true;
        }
        return parent::isGhosted($property);
    }

    /**
     * Return the collection class name the object is for.
     *
     * @return string
     */
    public function getClass()
    {
        return 'Calendar';
    }

}
