<?php
/**
 * Horde_ActiveSync_Connector_Exporter_Sync::
 *
 * @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_Connector_Exporter_Sync:: Responsible for outputing
 * blocks of WBXML responses in SYNC responses. E.g., sending all WBXML
 * necessary to transmit a new/changed message object to the client.
 *
 * @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_Connector_Exporter_Sync extends Horde_ActiveSync_Connector_Exporter_Base
{

    /**
     * Local cache of object ids we have already dealt with.
     *
     * @var array
     */
    protected $_seenObjects = array();

    /**
     * Currently syncing collection.
     *
     * @var array
     */
    protected $_currentCollection;


    /**
     * Set the changes to send to the client.
     *
     * @param array $changes  The changes array returned from the collection
     *                        handler.
     * @param array $collection  The collection we are currently syncing.
     */
    public function setChanges($changes, $collection = null)
    {
        parent::setChanges($changes, $collection);
        $this->_currentCollection = $collection;
    }

    /**
     * Sends the next change in the set to the client.
     *
     * @return boolean|Horde_Exception True if more changes can be sent false if
     *                                 all changes were sent, Horde_Exception if
     *                                 there was an error sending an item.
     */
    public function sendNextChange()
    {
        return $this->_sendNextChange();
    }

    /**
     * Send a message change over the wbxml stream
     *
     * @param string $id                              The uid of the message
     * @param Horde_ActiveSync_Message_Base $message  The message object
     */
    public function messageChange($id, Horde_ActiveSync_Message_Base $message)
    {
        // Just ignore any messages that are not from this collection and
        // prevent sending the same object twice in one request.
        if ($message->getClass() != $this->_currentCollection['class'] ||
            in_array($id, $this->_seenObjects)) {
            $this->_logger->notice(sprintf(
                'IGNORING message %s since it looks like it was already sent or does not belong to this collection. Class: %s, CurrentClass: %s',
                $id,
                $message->getClass(),
                $this->_currentCollection['class']));
            return;
        }

        // Ignore any empty objects.
        if ($message->isEmpty()) {
            $this->_logger->notice(sprintf(
                'IGNORING message %s since it looks like it does not contain any data. Class: %s, CurrentClass: %s',
                $id,
                $message->getClass(),
                $this->_currentCollection['class']));
            return;
        }

        // Remember this message
        $this->_seenObjects[] = $id;

        // Specify if this is an ADD or a MODIFY change?
        if ($message->flags === Horde_ActiveSync::FLAG_NEWMESSAGE) {
            $this->_encoder->startTag(Horde_ActiveSync::SYNC_ADD);
        } else {
            $this->_encoder->startTag(Horde_ActiveSync::SYNC_MODIFY);
        }

        // Send the message
        $this->_encoder->startTag(Horde_ActiveSync::SYNC_SERVERENTRYID);
        $this->_encoder->content($id);
        $this->_encoder->endTag();
        $this->_encoder->startTag(Horde_ActiveSync::SYNC_DATA);
        try {
            $message->encodeStream($this->_encoder);
        } catch (Horde_ActiveSync_Exception $e) {
            $this->_logger->err($e);
        }
        $this->_encoder->endTag();
        $this->_encoder->endTag();
    }

    /**
     * Stream a message deletion to the client.
     *
     * @param string $id  The uid of the message we are deleting.
     * @param boolean $soft  If true, send a SOFTDELETE, otherwise a REMOVE.
     */
    public function messageDeletion($id, $soft = false)
    {
        if ($soft) {
            $this->_encoder->startTag(Horde_ActiveSync::SYNC_SOFTDELETE);
        } else {
            $this->_encoder->startTag(Horde_ActiveSync::SYNC_REMOVE);
        }
        $this->_encoder->startTag(Horde_ActiveSync::SYNC_SERVERENTRYID);
        $this->_encoder->content($id);
        $this->_encoder->endTag();
        $this->_encoder->endTag();
    }

    /**
     * Move a message to a different folder.
     *
     * @param Horde_ActiveSync_Message_Base $message  The message
     */
    public function messageMove($message)
    {
    }

    /**
     * Send the SYNC_ADD response for any items added from the client.
     *
     * @param array $collection  The collection array.
     */
    public function syncAddResponse($collection)
    {
        foreach ($collection['clientids'] as $clientid => $serverid) {
            if ($serverid) {
                $status = Horde_ActiveSync_Request_Sync::STATUS_SUCCESS;
            } else {
                $status = Horde_ActiveSync_Request_Sync::STATUS_INVALID;
            }
            // Start SYNC_ADD
            $this->_encoder->startTag(Horde_ActiveSync::SYNC_ADD);

            // @todo. Need to use correct CLASS_SMS value here. CLASS_EMAIL
            // now interferes with Draft sync.
            // if ($collection['class'] == Horde_ActiveSync::CLASS_EMAIL) {
            //     $this->_encoder->startTag(Horde_ActiveSync::SYNC_FOLDERTYPE);
            //     $this->_encoder->content(Horde_ActiveSync::CLASS_SMS);
            //     $this->_encoder->endTag();
            // }

            // CLIENTENTRYID
            $this->_encoder->startTag(Horde_ActiveSync::SYNC_CLIENTENTRYID);
            $this->_encoder->content($clientid);
            $this->_encoder->endTag();

            // SERVERENTRYID
            if ($status == Horde_ActiveSync_Request_Sync::STATUS_SUCCESS) {
                $this->_encoder->startTag(Horde_ActiveSync::SYNC_SERVERENTRYID);
                $this->_encoder->content($serverid);
                $this->_encoder->endTag();
            }

            // EAS 16?
            $this->_sendEas16MessageResponse($serverid, $collection);

            // STATUS
            $this->_encoder->startTag(Horde_ActiveSync::SYNC_STATUS);
            $this->_encoder->content($status);
            $this->_encoder->endTag();

            // END SYNC_ADD
            $this->_encoder->endTag();
        }
    }

    /**
     * Send the SYNC_MODIFY response for each modified item that requires this
     * response. @see self::syncAddResponse()
     *
     * @param array $collection  The collection array.
     */
    public function syncModifiedResponse($collection)
    {
        if ($this->_as->device->version < Horde_ActiveSync::VERSION_SIXTEEN) {
            return;
        }

        foreach ($collection['modifiedids'] as $serverid) {
            switch ($collection['class']) {
            case Horde_ActiveSync::CLASS_CALENDAR:
                if (empty($collection['atchash'][$serverid])) {
                    continue 2;
                }
                break;
            case Horde_ActiveSync::CLASS_EMAIL:
                if (empty($collection['conversations'])) {
                    continue 2;
                }
                break;
            }
            // Start SYNC_MODIFY
            $this->_encoder->startTag(Horde_ActiveSync::SYNC_MODIFY);

            // EAS 16?
            $this->_sendEas16MessageResponse($serverid, $collection);

            // SYNC_STATUS
            $this->_encoder->startTag(Horde_ActiveSync::SYNC_STATUS);
            $this->_encoder->content(Horde_ActiveSync_Request_Sync::STATUS_SUCCESS);
            $this->_encoder->endTag();

            // End SYNC_MODIFY
            $this->_encoder->endTag();
        }
    }

    /**
     * Send the SYNC_MODIFY response for any items modified from the client
     * that require this response. Basically any items that have
     * AirSyncBaseAttachments changes.
     *
     * @param array $collection  The collection array.
     */
    public function modifyFailures($collection)
    {
        foreach ($collection['importfailures'] as $id => $reason) {
            $this->_encoder->startTag(Horde_ActiveSync::SYNC_MODIFY);

            $this->_encoder->startTag(Horde_ActiveSync::SYNC_FOLDERTYPE);
            $this->_encoder->content($collection['class']);
            $this->_encoder->endTag();

            $this->_encoder->startTag(Horde_ActiveSync::SYNC_SERVERENTRYID);
            $this->_encoder->content($id);
            $this->_encoder->endTag();

            $this->_encoder->startTag(Horde_ActiveSync::SYNC_STATUS);
            $this->_encoder->content($reason);
            $this->_encoder->endTag();

            $this->_encoder->endTag();
        }
    }

    /**
     * Send the SYNC_REMOVE response for any items deleted from the client but
     * were unable to be removed on the server. Usually due to not being found.
     *
     * @param array $collection  The collection array.
     */
    public function missingRemove($collection)
    {
        foreach ($collection['missing'] as $uid) {
            $this->_encoder->startTag(Horde_ActiveSync::SYNC_REMOVE);
            $this->_encoder->startTag(Horde_ActiveSync::SYNC_CLIENTENTRYID);
            $this->_encoder->content($uid);
            $this->_encoder->endTag();
            $this->_encoder->startTag(Horde_ActiveSync::SYNC_STATUS);
            $this->_encoder->content(Horde_ActiveSync_Request_Sync::STATUS_NOTFOUND);
            $this->_encoder->endTag();
            $this->_encoder->endTag();
        }
    }

    /**
     * Send the SYNC_FETCH response for any items requested by the client.
     *
     * @param array $collection  The collection array.
     */
    public function fetchIds($driver, $collection)
    {
        foreach ($collection['fetchids'] as $fetch_id) {
            try {
                $data = $driver->fetch($collection['serverid'], $fetch_id, $collection);
                $this->_encoder->startTag(Horde_ActiveSync::SYNC_FETCH);
                $this->_encoder->startTag(Horde_ActiveSync::SYNC_SERVERENTRYID);
                $this->_encoder->content($fetch_id);
                $this->_encoder->endTag();
                $this->_encoder->startTag(Horde_ActiveSync::SYNC_STATUS);
                $this->_encoder->content(Horde_ActiveSync_Request_Sync::STATUS_SUCCESS);
                $this->_encoder->endTag();
                $this->_encoder->startTag(Horde_ActiveSync::SYNC_DATA);
                $data->encodeStream($this->_encoder);
                $this->_encoder->endTag();
                $this->_encoder->endTag();
            } catch (Horde_Exception_NotFound $e) {
                $this->_logger->err(sprintf(
                    'Unable to fetch %s',
                    $fetch_id)
                );
                $this->_encoder->startTag(Horde_ActiveSync::SYNC_FETCH);
                $this->_encoder->startTag(Horde_ActiveSync::SYNC_SERVERENTRYID);
                $this->_encoder->content($fetch_id);
                $this->_encoder->endTag();
                $this->_encoder->startTag(Horde_ActiveSync::SYNC_STATUS);
                $this->_encoder->content(Horde_ActiveSync_Request_Sync::STATUS_NOTFOUND);
                $this->_encoder->endTag();
                $this->_encoder->endTag();
            }
        }
    }

    /**
     * Send the SYNC_FOLDERTYPE node if needed.
     *
     * @param array $collection  The collection array.
     */
    public function syncFolderType($collection)
    {
        // Not sent in > 12.0
        if ($this->_as->device->version <= Horde_ActiveSync::VERSION_TWELVE) {
            $this->_encoder->startTag(Horde_ActiveSync::SYNC_FOLDERTYPE);
            $this->_encoder->content($collection['class']);
            $this->_encoder->endTag();
        }
    }

    /**
     * Send the SYNC_SYNCKEY response appropriate for this response.
     *
     * @param array $collection  The collection array.
     */
    public function syncKey($collection)
    {
        $this->_encoder->startTag(Horde_ActiveSync::SYNC_SYNCKEY);
        if (!empty($collection['newsynckey'])) {
            $this->_encoder->content($collection['newsynckey']);
        } else {
            $this->_encoder->content($collection['synckey']);
        }
        $this->_encoder->endTag();
    }

    /**
     * Send the SYNC_FOLDERID value.
     *
     * @param array $collection  The collection array.
     */
    public function syncFolderId($collection)
    {
        $this->_encoder->startTag(Horde_ActiveSync::SYNC_FOLDERID);
        $this->_encoder->content($collection['id']);
        $this->_encoder->endTag();
    }

    /**
     * Send the SYNC_STATUS value.
     *
     * @param array $collection  The collection array.
     */
    public function syncStatus($statusCode)
    {
        $this->_encoder->startTag(Horde_ActiveSync::SYNC_STATUS);
        $this->_encoder->content($statusCode);
        $this->_encoder->endTag();
    }

    /**
     * Sends the appropriate message object reply within a SYNC_ADD or
     * SYNC_MODIFY block in a SYNC_REPLIES block for EAS >= 16 clients.
     *
     * @param string $serverid   The serverid of the message being sent.
     * @param array $collection  The collection array.
     */
    protected function _sendEas16MessageResponse($serverid, $collection)
    {
        // @todo. Use a Exporter_Sync_Response_[Mail|Message|] object.
        if ($this->_as->device->version >= Horde_ActiveSync::VERSION_SIXTEEN &&
            $collection['class'] == Horde_ActiveSync::CLASS_CALENDAR &&
            !empty($collection['atchash'][$serverid])) {

            $this->_encoder->startTag(Horde_ActiveSync::SYNC_SERVERENTRYID);
            $this->_encoder->content($serverid);
            $this->_encoder->endTag();

            $msg = $this->_as->messageFactory('Appointment');
            $msg->uid = $serverid;
            foreach ($collection['atchash'][$serverid]['add'] as $clientid => $filereference) {
                $atc = $this->_as->messageFactory('AirSyncBaseAttachment');
                $atc->clientid = $clientid;
                $atc->attname = $filereference;
                $msg->airsyncbaseattachments[] = $atc;
            }
            $this->_encoder->startTag(Horde_ActiveSync::SYNC_DATA);
            $msg->encodeStream($this->_encoder);
            $this->_encoder->endTag();
        } elseif ($this->_as->device->version >= Horde_ActiveSync::VERSION_SIXTEEN &&
                  $collection['class'] == Horde_ActiveSync::CLASS_EMAIL &&
                  !empty($collection['conversations'][$serverid])) {
            $msg = $this->_as->messageFactory('Mail');
            $msg->conversationid = $collection['conversations'][$serverid][0];
            $msg->conversationindex = $collection['conversations'][$serverid][1];

            if (!empty($collection['atchash'][$serverid]['add'])) {
                foreach ($collection['atchash'][$serverid]['add'] as $clientid => $filereference) {
                    $atc = $this->_as->messageFactory('AirSyncBaseAttachment');
                    $atc->clientid = $clientid;
                    $atc->attname = $filereference;
                    $msg->addAttachment($atc);
                }
            }
            $this->_encoder->startTag(Horde_ActiveSync::SYNC_SERVERENTRYID);
            $this->_encoder->content($serverid);
            $this->_encoder->endTag();

            $this->_encoder->startTag(Horde_ActiveSync::SYNC_DATA);
            $msg->encodeStream($this->_encoder);
            $this->_encoder->endTag();
        }
    }

    protected function _getNextChange()
    {
        $change = $this->_changes[$this->_step];
        if (!is_array($change)) {
            // This is an initial sync, so we know it's a CHANGE_TYPE_CHANGE
            // and a new message with no flag changes etc...
            $change = array(
                'id' => $change,
                'type' => Horde_ActiveSync::CHANGE_TYPE_CHANGE,
                'flags' => Horde_ActiveSync::FLAG_NEWMESSAGE
            );
        }

        return $change;
    }

    /**
     * Sends the next message change to the client.
     *
     * @return @see self::sendNextChange()
     */
    protected function _sendNextChange()
    {
        if ($this->_step >= count($this->_changes)) {
            return false;
        }

        // Get the next change.
        $change = $this->_getNextChange();

        // Ignore entries with no UID.
        while (empty($change['id']) && $this->_step < count($this->_changes) - 1) {
            $this->_logger->notice(sprintf(
                'Missing UID value for an entry in: %s. Details: %s.',
                $this->_currentCollection['id'],
                print_r($change, true)
            ));
            $this->_step++;
            $change = $this->_getNextChange();
        }

        // Actually export the change by calling the appropriate
        // method to output the correct wbxml for this change.
        if (empty($change['ignore'])) {
            switch($change['type']) {
            case Horde_ActiveSync::CHANGE_TYPE_CHANGE:
            case Horde_ActiveSync::CHANGE_TYPE_DRAFT:
                try {
                    $message = $this->_as->driver->getMessage(
                        $this->_currentCollection['serverid'],
                        $change['id'],
                        $this->_currentCollection);
                    $message->flags = (isset($change['flags'])) ? $change['flags'] : false;
                    $this->messageChange($change['id'], $message);
                } catch (Horde_Exception_NotFound $e) {
                    $this->_logger->notice(sprintf(
                        'Message gone or error reading message from server: %s',
                        $e->getMessage()));
                    $this->_as->state->updateState($change['type'], $change);
                    $this->_step++;
                    return $e;
                } catch (Horde_ActiveSync_Exception_TemporaryFailure $e) {
                    $this->_logger->err(sprintf(
                        'Backend server reported an error: %s.',
                        $e->getMessage())
                    );
                    throw $e;
                } catch (Horde_ActiveSync_Exception $e) {
                    $this->_logger->err(sprintf(
                        'Unknown backend error skipping message: %s',
                        $e->getMessage())
                    );
                    $this->_as->state->updateState($change['type'], $change);
                    $this->_step++;
                    return $e;
                }
                break;

            case Horde_ActiveSync::CHANGE_TYPE_DELETE:
                $this->messageDeletion($change['id']);
                break;

            case Horde_ActiveSync::CHANGE_TYPE_SOFTDELETE:
                $this->messageDeletion($change['id'], true);
                break;

            case Horde_ActiveSync::CHANGE_TYPE_FLAGS:
                // Read flag.
                $message = Horde_ActiveSync::messageFactory('Mail');
                $message->flags = Horde_ActiveSync::CHANGE_TYPE_CHANGE;
                $message->read = isset($change['flags']['read']) ? $change['flags']['read'] : false;

                // "Flagged" flag.
                if (isset($change['flags']['flagged']) && $this->_as->device->version >= Horde_ActiveSync::VERSION_TWELVE) {
                    $flag = Horde_ActiveSync::messageFactory('Flag');
                    $flag->flagstatus = $change['flags']['flagged'] == 1
                        ? Horde_ActiveSync_Message_Flag::FLAG_STATUS_ACTIVE
                        : Horde_ActiveSync_Message_Flag::FLAG_STATUS_CLEAR;
                    $message->flag = $flag;
                }

                // Categories
                if (!empty($change['categories']) && $this->_as->device->version > Horde_ActiveSync::VERSION_TWELVEONE) {
                    $message->categories = $change['categories'];
                }

                // Verbs
                if ($this->_as->device->version >= Horde_ActiveSync::VERSION_FOURTEEN) {
                    if (isset($change['flags'][Horde_ActiveSync::CHANGE_REPLY_STATE])) {
                        $message->lastverbexecuted = Horde_ActiveSync_Message_Mail::VERB_REPLY_SENDER;
                        $message->lastverbexecutiontime = new Horde_Date($change['flags'][Horde_ActiveSync::CHANGE_REPLY_STATE]);
                    } elseif (isset($change['flags'][Horde_ActiveSync::CHANGE_REPLYALL_STATE])) {
                        $message->lastverbexecuted = Horde_ActiveSync_Message_Mail::VERB_REPLY_ALL;
                        $message->lastverbexecutiontime = new Horde_Date($change['flags'][Horde_ActiveSync::CHANGE_REPLYALL_STATE]);
                    } elseif (isset($change['flags'][Horde_ActiveSync::CHANGE_FORWARD_STATE])) {
                        $message->lastverbexecuted = Horde_ActiveSync_Message_Mail::VERB_FORWARD;
                        $message->lastverbexecutiontime = new Horde_Date($change['flags'][Horde_ActiveSync::CHANGE_FORWARD_STATE]);
                    }
                }

                // Export it.
                $this->messageChange($change['id'], $message);
                break;

            case Horde_ActiveSync::CHANGE_TYPE_MOVE:
                $this->messageMove($change['id'], $change['parent']);
                break;
            }
        }

        // Update the state.
        $this->_as->state->updateState($change['type'], $change);
        $this->_step++;
        return true;
    }

}
