<?php
/**
 * Copyright 2003-2016 Horde LLC (http://www.horde.org/)
 *
 * See the enclosed file COPYING for license information (LGPL). If you
 * did not receive this file, see http://www.horde.org/licenses/lgpl21.
 *
 * @author  Anthony Mills <amills@pyramid6.com>
 * @package SyncMl
 */
class Horde_SyncMl_Sync
{
    /**
     * @see Horde_SyncMl_Sync::_state
     */
    const STATE_INIT =      0;
    const STATE_SYNC =      1;
    const STATE_MAP =       2;
    const STATE_COMPLETED = 3;

    /**
     * Target (client) URI (database).
     *
     * @var string
     */
    protected $_targetLocURI;

    /**
     * Source (server) URI (database).
     *
     * @var string
     */
    protected $_sourceLocURI;

    /**
     * The synchronization method, one of the Horde_SyncMl::ALERT_* constants.
     *
     * @var integer
     */
    protected $_syncType;

    /**
     * Counts the <Sync>s sent by the server.
     *
     * @var integer
     */
    protected $_syncsSent = 0;

    /**
     * Counts the <Sync>s received by the server. Currently unused.
     *
     * @var integer
     */
    protected $_syncsReceived = 0;

    /**
     * Map data is expected whenever an add is sent to the client.
     *
     * @var boolean
     */
    protected $_expectingMapData = false;

    /**
     * State of the current sync.
     *
     * A sync starts in Horde_SyncMl_Sync::STATE_INIT and moves on to the next state with every
     * <Final> received from the client: Horde_SyncMl_Sync::STATE_INIT, Horde_SyncMl_Sync::STATE_SYNC, Horde_SyncMl_Sync::STATE_MAP,
     * Horde_SyncMl_Sync::STATE_COMPLETED.  Horde_SyncMl_Sync::STATE_MAP doesn't occur for _FROM_CLIENT syncs.
     *
     * @var constant
     */
    protected $_state = Horde_SyncMl_Sync::STATE_INIT;

    /**
     * Sync Anchors determine the interval from which changes are retrieved.
     *
     * @var integer
     */
    protected $_clientAnchorNext;

    protected $_serverAnchorLast;
    protected $_serverAnchorNext;

    /**
     * Number of objects that have been sent to the server for adding.
     *
     * @var integer
     */
    protected $_client_add_count = 0;

    /**
     * Number of objects that have been sent to the server for replacement.
     *
     * @var integer
     */
    protected $_client_replace_count = 0;

    /**
     * Number of objects that have been sent to the server for deletion.
     *
     * @var integer
     */
    protected $_client_delete_count = 0;

    /**
     * Add due to client replace request when map entry is not found. Happens
     * during SlowSync.
     *
     * @var integer
     */
    protected $_client_addreplaces = 0;

    /**
     * Number of objects that have been sent to the client for adding.
     *
     * @var integer
     */
    protected $_server_add_count = 0;

    /**
     * Number of objects that have been sent to the client for replacement.
     *
     * @var integer
     */
    protected $_server_replace_count = 0;

    /**
     * Number of objects that have been sent to the client for deletion.
     *
     * @var integer
     */
    protected $_server_delete_count = 0;

    /**
     * Number of failed actions, for logging purposes only.
     *
     * @var integer
     */
    protected $_errors = 0;

    /**
     * List of object UIDs (in the keys) that have been added on the server
     * since the last synchronization and are supposed to be sent to the
     * client.
     *
     * @var array
     */
    protected $_server_adds;

    /**
     * List of object UIDs (in the keys) that have been changed on the server
     * since the last synchronization and are supposed to be sent to the
     * client.
     *
     * @var array
     */
    protected $_server_replaces;

    /**
     * List of object UIDs (in the keys) that have been deleted on the server
     * since the last synchronization and are supposed to be sent to the
     * client.
     *
     * @var array
     */
    protected $_server_deletes;

    /**
     * List of task UIDs (in the keys) that have been added on the server
     * since the last synchronization and are supposed to be sent to the
     * client.
     *
     * This is only used for clients handling tasks and events in one
     * database. We need to seperately store the server tasks adds, so when we
     * get a Map command from the client, we know whether to put this in tasks
     * or calendar.
     *
     * @var array
     */
    protected $_server_task_adds;
    
    /**
     * Array holding the remaining content when splitting a large object into
     * multiple messages. Keys are numeric, values are:
     * command, chunkContent, clientContentType, clientEncodingType, cuid, suid
     *
     * @var array
     */
    protected $_server_largeobj;
 
    /**
     *
     * @param string $syncType
     * @param string $serverURI
     * @param string $clientURI
     * @param integer $serverAnchorLast
     * @param integer $serverAnchorNext
     * @param string $clientAnchorNext
     */
    public function __construct($syncType, $serverURI, $clientURI, $serverAnchorLast,
                         $serverAnchorNext, $clientAnchorNext)
    {
        $this->_syncType = $syncType;
        $this->_targetLocURI = $serverURI;
        $this->_sourceLocURI = $clientURI;
        $this->_clientAnchorNext = $clientAnchorNext;
        $this->_serverAnchorLast = $serverAnchorLast;
        $this->_serverAnchorNext = $serverAnchorNext;
    }

    /**
     * Here's where the actual processing of a client-sent Sync Item takes
     * place. Entries are added, deleted or replaced from the server database
     * by using backend API calls.
     *
     * @todo maybe this should be moved to SyncItem
     *
     * @param $output
     * @param Horde_SyncMl_SyncElement $item
     */
    public function handleClientSyncItem(&$output, &$item)
    {
        global $backend;

        $backend->logMessage(
            'Handling <' . $item->elementType . '> sent from client', 'DEBUG');

        /* if size of item is set: check it first */
        if ($item->size > 0) {
            if (strlen($item->content) != $item->size &&
                /* For some strange reason the SyncML conformance test suite
                 * sends an item with length n and size tag=n+1 and expects us
                 * the accept it. Happens in test 1301.  So ignore this to be
                 * conformant (and wrong). */
                strlen($item->content) + 1 != $item->size) {
                $item->responseCode = Horde_SyncMl::RESPONSE_SIZE_MISMATCH;
                $backend->logMessage(
                    'Item size mismatch. Size reported as ' . $item->size
                    . ' but actual size is ' . strlen($item->content), 'ERR');
                $this->_errors++;
                return false;
            }
        }

        $device = $GLOBALS['backend']->state->getDevice();
        $hordedatabase = $database = $this->_targetLocURI;
        $content = $item->content;
        if ($item->contentFormat == 'b64') {
            $content = base64_decode($content);
        }

        if (($item->contentType == 'text/calendar' ||
             $item->contentType == 'text/x-vcalendar') &&
            $backend->normalize($database) == 'calendar' &&
            $device->handleTasksInCalendar()) {
            $tasksincalendar = true;
            /* Check if the client sends us a vtodo in a calendar sync. */
            if (preg_match('/(\r\n|\r|\n)BEGIN[^:]*:VTODO/',
                           "\n" . $content)) {
                $hordedatabase = $this->_taskToCalendar($backend->normalize($database));
             }
        } else {
            $tasksincalendar = false;
        }

        /* Use contentType explicitly specified in this sync command. */
        $contentType = $item->contentType;

        /* If not provided, use default from device info. */
        if (!$contentType) {
            $contentType = $device->getPreferredContentType($hordedatabase);
        }

        if ($item->elementType != 'Delete') {
            list($content, $contentType) = $device->convertClient2Server($content, $contentType);
        }

        $cuid = $item->cuid;
        $suid = false;

        if ($item->elementType == 'Add') {
            /* Handle client add requests.
             *
             * @todo: check if this $cuid is already present and then maybe do
             * an replace instead? */
            $suid = $backend->addEntry($hordedatabase, $content, $contentType, $cuid);
            if (!is_a($suid, 'PEAR_Error')) {
                $this->_client_add_count++;
                $item->responseCode = Horde_SyncMl::RESPONSE_ITEM_ADDED;
                $backend->logMessage('Added client entry as ' . $suid, 'DEBUG');
            } else {
                $this->_errors++;
                /* @todo: better response code. */
                $item->responseCode = Horde_SyncMl::RESPONSE_NO_EXECUTED;
                $backend->logMessage('Error in adding client entry: ' . $suid->message, 'ERR');
            }
        } elseif ($item->elementType == 'Delete') {
            /* Handle client delete requests. */
            $ok = $backend->deleteEntry($database, $cuid);
            if (!$ok && $tasksincalendar) {
                $backend->logMessage(
                    'Task ' . $cuid . ' deletion sent with calendar request', 'DEBUG');
                $ok = $backend->deleteEntry($this->_taskToCalendar($backend->normalize($database)), $cuid);
            }

            if ($ok) {
                $this->_client_delete_count++;
                $item->responseCode = Horde_SyncMl::RESPONSE_OK;
                $backend->logMessage('Deleted entry ' . $suid . ' due to client request', 'DEBUG');
            } else {
                $this->_errors++;
                $item->responseCode = Horde_SyncMl::RESPONSE_ITEM_NO_DELETED;
                $backend->logMessage('Failure deleting client entry, maybe already disappeared from server', 'DEBUG');
            }

        } elseif ($item->elementType == 'Replace') {
            /* Handle client replace requests. */
            $suid = $backend->replaceEntry($hordedatabase, $content,
                                           $contentType, $cuid);

            if (!is_a($suid, 'PEAR_Error')) {
                $this->_client_replace_count++;
                $item->responseCode = Horde_SyncMl::RESPONSE_OK;
                $backend->logMessage('Replaced entry ' . $suid . ' due to client request', 'DEBUG');
            } else {
                $backend->logMessage($suid->message, 'DEBUG');

                /* Entry may have been deleted; try adding it. */
                $suid = $backend->addEntry($hordedatabase, $content,
                                           $contentType, $cuid);
                if (!is_a($suid, 'PEAR_Error')) {
                    $this->_client_addreplaces++;
                    $item->responseCode = Horde_SyncMl::RESPONSE_ITEM_ADDED;
                    $backend->logMessage(
                        'Added instead of replaced entry ' . $suid, 'DEBUG');
                } else {
                    $this->_errors++;
                    /* @todo: better response code. */
                    $item->responseCode = Horde_SyncMl::RESPONSE_NO_EXECUTED;
                    $backend->logMessage(
                        'Error in adding client entry due to replace request: '
                        . $suid->message, 'ERR');
                }
            }
        } else {
            $backend->logMessage(
                'Unexpected elementType: ' . $item->elementType, 'ERR');
        }

        return $suid;
    }

    /**
     * Creates a <Sync> output containing the server changes.
     *
     * @todo Check for Mem/FreeMem and Mem/FreeID when checking MaxObjSize
     */
    public function createSyncOutput(&$output)
    {
        global $backend, $messageFull;

        $backend->logMessage(
            'Creating <Sync> output for server changes in database '
            . $this->_targetLocURI, 'DEBUG');

        /* If sync data from client only, nothing to be done here. */
        if($this->_syncType == Horde_SyncMl::ALERT_ONE_WAY_FROM_CLIENT ||
           $this->_syncType == Horde_SyncMl::ALERT_REFRESH_FROM_CLIENT) {
            return;
        }

        /* If one sync has been sent an no pending data: bail out. */
        if ($this->_syncsSent > 0 && !$this->hasPendingElements()) {
            return;
        }

        /* $messageFull will be set to true to indicate that there's no room
         * for other data in this message. If it's false (empty) and there are
         * pending Sync data, the final command will sent the pending data. */
        $messageFull = false;

        $state = $GLOBALS['backend']->state;
        $device = $state->getDevice();
        $di = $state->deviceInfo;
        $contentType = $device->getPreferredContentTypeClient(
            $this->_targetLocURI, $this->_sourceLocURI);
        $contentTypeTasks = $device->getPreferredContentTypeClient(
            'tasks', $this->_sourceLocURI);
        if ($state->deviceInfo && $state->deviceInfo->CTCaps) {
            $fields = array($contentType => isset($state->deviceInfo->CTCaps[$contentType]) ? $state->deviceInfo->CTCaps[$contentType] : null,
                            $contentTypeTasks => isset($state->deviceInfo->CTCaps[$contentTypeTasks]) ? $state->deviceInfo->CTCaps[$contentTypeTasks] : null);
        } else {
            $fields = array($contentType => null, $contentTypeTasks => null);
        }

        /* If server modifications are not retrieved yet (first Sync element),
         * do it now. */
        if (!is_array($this->_server_adds)) {
            $backend->logMessage(
                'Compiling server changes from '
                . date('Y-m-d H:i:s', $this->_serverAnchorLast)
                . ' to ' . date('Y-m-d H:i:s', $this->_serverAnchorNext), 'DEBUG');

            $result = $this->_retrieveChanges($this->_targetLocURI,
                                              $this->_server_adds,
                                              $this->_server_replaces,
                                              $this->_server_deletes);
            if (is_a($result, 'PEAR_Error')) {
                return;
            }

            /* If tasks are handled inside calendar, do the same again for
             * tasks. Merge resulting arrays. */
            if ($backend->normalize($this->_targetLocURI) == 'calendar' &&
                $device->handleTasksInCalendar()) {
                $this->_server_task_adds = $deletes2 = $replaces2 = array();
                $result = $this->_retrieveChanges('tasks',
                                                  $this->_server_task_adds,
                                                  $replaces2,
                                                  $deletes2);
                if (is_a($result, 'PEAR_Error')) {
                    return;
                }
                $this->_server_adds = array_merge($this->_server_adds,
                                                  $this->_server_task_adds);
                $this->_server_replaces = array_merge($this->_server_replaces,
                                                      $replaces2);
                $this->_server_deletes = array_merge($this->_server_deletes,
                                                     $deletes2);
            }

            $numChanges = count($this->_server_adds)
                + count($this->_server_replaces)
                + count($this->_server_deletes);
            $backend->logMessage(
                'Sending ' . $numChanges . ' server changes ' . 'for client URI '
                . $this->_targetLocURI, 'DEBUG');

            /* Now we know the number of Changes and can send them to the
             * client. */
            if ($di->SupportNumberOfChanges) {
                $output->outputSyncStart($this->_sourceLocURI,
                                         $this->_targetLocURI,
                                         $numChanges);
            } else {
                $output->outputSyncStart($this->_sourceLocURI,
                                         $this->_targetLocURI);
            }
        } else {
            /* Package continued. Sync in subsequent message. */
            $output->outputSyncStart($this->_sourceLocURI,
                                     $this->_targetLocURI);
        }

        /* We sent a Sync. So at least we espect a status response and thus
         * another message from the client. */
        $GLOBALS['message_expectresponse'] = true;
 
        /* Handle large objects. */
        if (isset($this->_server_largeobj)) {
            list($command, $chunkContent, $clientContentType, $clientEncodingType, $cuid, $suid) = $this->_server_largeobj;
            $backend->logMessage("Continuing sending large object from server: ".$suid, 'DEBUG');
            $chunkLength = $state->maxMsgSize - $output->getOutputSize() - Horde_SyncMl::MSG_CHUNK_LEN - Horde_SyncMl::MSG_TRAILER_LEN;
            if (($chunkContent = $this->_getServerLargeObjChunk($chunkLength)) !== false) {
                $moreData = $messageFull = isset($this->_server_largeobj);
                $cmdId = $output->outputSyncCommand($command, $chunkContent,
                                                    $clientContentType,
                                                    $clientEncodingType,
                                                    $cuid, $suid,
                                                    null, $moreData);
                if ($messageFull) {
                    $output->outputSyncEnd();
                    $this->_syncsSent += 1;
                    return;
                }
            }
        }

        /* Handle deletions. */
        $deletes = $this->_server_deletes;
        foreach ($deletes as $suid => $cuid) {
            /* Check if we have space left in the message. */
            if ($state->maxMsgSize - $output->getOutputSize() < Horde_SyncMl::MSG_TRAILER_LEN) {
                $backend->logMessage(
                    'Maximum message size ' . $state->maxMsgSize
                    . ' approached during delete; current size: '
                    . $output->getOutputSize(), 'DEBUG');
                $messageFull = true;
                $output->outputSyncEnd();
                $this->_syncsSent += 1;
                return;
            }
            $backend->logMessage(
                "Sending delete from server: client id $cuid, server id $suid", 'DEBUG');
            /* Create a Delete request for client. */
            $cmdId = $output->outputSyncCommand('Delete', null, null, null, $cuid, null);
            unset($this->_server_deletes[$suid]);
            $state->serverChanges[$state->messageID][$this->_targetLocURI][$cmdId] = array($suid, $cuid);
            $this->_server_delete_count++;
        }

        /* Handle additions. */
        $adds = $this->_server_adds;
        foreach ($adds as $suid => $cuid) {
            $backend->logMessage("Sending add from server: $suid", 'DEBUG');

            $syncDB = isset($this->_server_task_adds[$suid]) ? 'tasks' : $this->_targetLocURI;
            $ct = isset($this->_server_task_adds[$suid]) ? $contentTypeTasks : $contentType;

            $c = $backend->retrieveEntry($syncDB, $suid, $ct, $fields[$ct]);
            /* Item in history but not in database. Strange, but can
             * happen. */
            if (is_a($c, 'PEAR_Error')) {
                $backend->logMessage(
                    'API export call for ' . $suid . ' failed: '
                    . $c->getMessage(), 'ERR');
            } else {
                list($clientContent, $clientContentType, $clientEncodingType) =
                    $device->convertServer2Client($c, $contentType, $syncDB);
                /* Trim clientContent to calculate correct size. */
                $clientContent = trim($clientContent);
                $clientContentLength = strlen($clientContent);
                /* Check if we have space left in the message. */
                if (($state->maxMsgSize - $output->getOutputSize() - $clientContentLength) < Horde_SyncMl::MSG_TRAILER_LEN) {
                    $backend->logMessage(
                        'Maximum message size ' . $state->maxMsgSize
                        . ' approached during add; current size: '
                        . $output->getOutputSize(), 'DEBUG');
                    if ($clientContentLength + Horde_SyncMl::MSG_DEFAULT_LEN > $state->maxMsgSize) {
                        if (!$di->SupportLargeObjs) {
                            $backend->logMessage(
                                'Data item won\'t fit into a single message. Client doesn\'t support large objects, so item will be skipped.', 'DEBUG');
                            unset($this->_server_adds[$suid]);
                            continue;
                        } elseif ($state->maxObjSize < $clientContentLength) {
                            $backend->logMessage(
                                'Data item won\'t fit into a single message. Item size ' . $clientContentLength . ' exceeds maximum object size ' . $state->maxObjSize . ', so item will be skipped.', 'DEBUG');
                            unset($this->_server_adds[$suid]);
                            continue;
                        } else {
                            $backend->logMessage(
                                'Data item won\'t fit into a single message. Split content into chunks and send in multiple messages.', 'DEBUG');
                            $this->_server_largeobj = array('Add', $clientContent,
                                                            $clientContentType,
                                                            $clientEncodingType,
                                                            null, $suid);
                            $chunkLength = $state->maxMsgSize - $output->getOutputSize() - Horde_SyncMl::MSG_CHUNK_LEN - Horde_SyncMl::MSG_TRAILER_LEN;
                            if (($chunkContent = $this->_getServerLargeObjChunk($chunkLength)) !== false) {
                                $cmdId = $output->outputSyncCommand('Add', $chunkContent,
                                                                    $clientContentType,
                                                                    $clientEncodingType,
                                                                    null, $suid,
                                                                    $clientContentLength, true);
                                /* @todo: _server_add_count, _server_adds and serverChanges on first or last chunk? */
                                $this->_server_add_count++;
                                unset($this->_server_adds[$suid]);
                                $state->serverChanges[$state->messageID][$this->_targetLocURI][$cmdId] = array($suid, 0);
                                $messageFull = true;
                                $output->outputSyncEnd();
                                $this->_syncsSent += 1;
                                return;
                            }
                        }
                    }
                    $messageFull = true;
                    $output->outputSyncEnd();
                    $this->_syncsSent += 1;
                    return;
                }

                /* @todo: on SlowSync send Replace instead! */
                // $output->outputSyncCommand($refts == 0 ? 'Replace' : 'Add',
                $cmdId = $output->outputSyncCommand('Add', $clientContent,
                                                    $clientContentType,
                                                    $clientEncodingType,
                                                    null, $suid);
                $this->_server_add_count++;
                $state->serverChanges[$state->messageID][$this->_targetLocURI][$cmdId] = array($suid, 0);
            }
            unset($this->_server_adds[$suid]);
        }

        if ($this->_server_add_count) {
            $this->_expectingMapData = true;
        }

        /* Handle Replaces. */
        $replaces = $this->_server_replaces;
        foreach ($replaces as $suid => $cuid) {
            $syncDB = isset($replaces2[$suid]) ? 'tasks' : $this->_targetLocURI;
            $ct = isset($replaces2[$suid]) ? $contentTypeTasks : $contentType;
            $c = $backend->retrieveEntry($syncDB, $suid, $ct, $fields[$ct]);
            if (is_a($c, 'PEAR_Error')) {
                /* Item in history but not in database. */
                unset($this->_server_replaces[$suid]);
                continue;
            }

            $backend->logMessage(
                "Sending replace from server: $suid", 'DEBUG');
            list($clientContent, $clientContentType, $clientEncodingType) =
                $device->convertServer2Client($c, $contentType, $syncDB);
            /* Trim clientContent to calculate correct size. */
            $clientContent = trim($clientContent);
            $clientContentLength = strlen($clientContent);
            /* Check if we have space left in the message. */
            if (($state->maxMsgSize - $output->getOutputSize() - $clientContentLength) < Horde_SyncMl::MSG_TRAILER_LEN) {
                $backend->logMessage(
                    'Maximum message size ' . $state->maxMsgSize
                    . ' approached during replace; current size: '
                    . $output->getOutputSize(), 'DEBUG');
                if ($clientContentLength + Horde_SyncMl::MSG_DEFAULT_LEN > $state->maxMsgSize) {
                    if (!$di->SupportLargeObjs) {
                        $backend->logMessage(
                            'Data item won\'t fit into a single message. Client doesn\'t support large objects, so item will be skipped.', 'DEBUG');
                        unset($this->_server_adds[$suid]);
                        continue;
                    } elseif ($state->maxObjSize < $clientContentLength) {
                        $backend->logMessage(
                            'Data item won\'t fit into a single message. Item size ' . $clientContentLength . ' exceeds maximum object size ' . $state->maxObjSize . ', so item will be skipped.', 'DEBUG');
                        unset($this->_server_adds[$suid]);
                        continue;
                    } else {
                        $backend->logMessage(
                            'Data item won\'t fit into a single message. Split content into chunks and send in multiple messages.', 'DEBUG');
                        $this->_server_largeobj = array('Replace', $clientContent,
                                                        $clientContentType,
                                                        $clientEncodingType,
                                                        $cuid, null);
                        $chunkLength = $state->maxMsgSize - $output->getOutputSize() - Horde_SyncMl::MSG_CHUNK_LEN - Horde_SyncMl::MSG_TRAILER_LEN;
                        if (($chunkContent = $this->_getServerLargeObjChunk($chunkLength)) !== false) {
                            $cmdId = $output->outputSyncCommand('Replace', $chunkContent,
                                                                $clientContentType,
                                                                $clientEncodingType,
                                                                $cuid, null,
                                                                $clientContentLength, true);
                            /* @todo: _server_replace_count, _server_replaces and serverChanges on first or last chunk? */
                            $this->_server_replace_count++;
                            unset($this->_server_replaces[$suid]);
                            $state->serverChanges[$state->messageID][$this->_targetLocURI][$cmdId] = array($suid, $cuid);
                            $messageFull = true;
                            $output->outputSyncEnd();
                            $this->_syncsSent += 1;
                            return;
                        }
                    }
                }
                $messageFull = true;
                $output->outputSyncEnd();
                $this->_syncsSent += 1;
                return;
            }
            $cmdId = $output->outputSyncCommand('Replace', $clientContent,
                                                $clientContentType,
                                                $clientEncodingType,
                                                $cuid, null);
            $this->_server_replace_count++;
            unset($this->_server_replaces[$suid]);
            $state->serverChanges[$state->messageID][$this->_targetLocURI][$cmdId] = array($suid, $cuid);
        }

        /* Finished! Send closing </Sync>. */
        $output->outputSyncEnd();
        $this->_syncsSent += 1;
    }

    /**
     * Retrieves and condenses the changes on the server side since the last
     * synchronization.
     *
     * @param string $syncDB   The database being synchronized.
     * @param array $adds      Will be set with the server-client-uid mappings
     *                         of added objects.
     * @param array $replaces  Will be set with the server-client-uid mappings
     *                         of changed objects.
     * @param array $deletes   Will be set with the server-client-uid mappings
     *                         of deleted objects.
     */
    protected function _retrieveChanges($syncDB, &$adds, &$replaces, &$deletes)
    {
        $adds = $replaces = $deletes = array();
        if ($syncDB == 'configuration') {
            return;
        }
        $result = $GLOBALS['backend']->getServerChanges($syncDB,
                                                        $this->_serverAnchorLast,
                                                        $this->_serverAnchorNext,
                                                        $adds, $replaces, $deletes);
        if (is_a($result, 'PEAR_Error')) {
            $GLOBALS['backend']->logMessage($result, 'ERR');
            return $result;
        }
    }

    /**
     * Notifies the sync that a final has been received by the client.
     *
     * Depending on the current state of the sync this can mean various
     * things:
     * a) Init phase (Alerts) done. Next package contaings actual syncs.
     * b) Sync sending from client done. Next package are maps (or finish
     *    or finish if ONE_WAY_FROM_CLIENT sync
     * c) Maps finished, completly done.
     */
    public function handleFinal(&$output, $debug = false)
    {
        switch ($this->_state) {
        case Horde_SyncMl_Sync::STATE_INIT:
            $state = 'Init';
            break;
        case Horde_SyncMl_Sync::STATE_SYNC:
            $state = 'Sync';
            break;
        case Horde_SyncMl_Sync::STATE_MAP:
            $state = 'Map';
            break;
        case Horde_SyncMl_Sync::STATE_COMPLETED:
            $state = 'Completed';
            break;
        }

        $GLOBALS['backend']->logMessage('Handle <Final> for state ' . $state, 'DEBUG');

        switch ($this->_state) {
        case Horde_SyncMl_Sync::STATE_INIT:
            $this->_state = Horde_SyncMl_Sync::STATE_SYNC;
            break;
        case Horde_SyncMl_Sync::STATE_SYNC:
            /* Received all client Sync data, now we are allowed to send
             * server sync data. */
            if (!$debug) {
                $this->createSyncOutput($output);
            }

            // FROM_CLIENT_SYNC doeesn't require a MAP package:
            if ($this->_syncType == Horde_SyncMl::ALERT_ONE_WAY_FROM_CLIENT ||
                $this->_syncType == Horde_SyncMl::ALERT_REFRESH_FROM_CLIENT ||
                !$this->_expectingMapData) {
                $this->_state = Horde_SyncMl_Sync::STATE_COMPLETED;
            } else {
                $this->_state = Horde_SyncMl_Sync::STATE_MAP;
            }
            break;
        case Horde_SyncMl_Sync::STATE_MAP:
            $this->_state = Horde_SyncMl_Sync::STATE_COMPLETED;
            break;
        }
    }

    /**
     * Returns true if there are still outstanding server sync items to
     * be sent to the client.
     *
     * This is the case if the MaxMsgSize has been reached and the pending
     * elements are to be sent in another message.
     */
    public function hasPendingElements()
    {
        if (!is_array($this->_server_adds)) {
            /* Changes not compiled yet: not pending: */
            return false;
        }
 
        return (count($this->_server_adds) + count($this->_server_replaces) + count($this->_server_deletes) + (int)(bool)$this->_server_largeobj) > 0;
    }
 
    public function addSyncReceived()
    {
        $this->_syncsReceived++;
    }

    /* Currently unused */
    public function getSyncsReceived()
    {
        return $this->_syncsReceived;
    }

    public function isComplete()
    {
        return $this->_state == Horde_SyncMl_Sync::STATE_COMPLETED;
    }

    /**
     * Completes a sync once everything is done: store the sync anchors so the
     * next sync can be a delta sync and produce some debug info.
     */
    public function closeSync()
    {
        $GLOBALS['backend']->writeSyncAnchors($this->_targetLocURI,
                                              $this->_clientAnchorNext,
                                              $this->_serverAnchorNext);

        $s = sprintf(
            'Finished sync of database %s user %s dev %s. Failures: %d; '
            . 'changes from client (Add, Replace, Delete, AddReplaces): %d, %d, %d, %d; '
            . 'changes from server (Add, Replace, Delete): %d, %d, %d',
            $this->_targetLocURI,
       $GLOBALS['backend']->getuser(),
       $GLOBALS['backend']->getSyncDeviceID(),
            $this->_errors,
            $this->_client_add_count,
            $this->_client_replace_count,
            $this->_client_delete_count,
            $this->_client_addreplaces,
            $this->_server_add_count,
            $this->_server_replace_count,
            $this->_server_delete_count);
        $GLOBALS['backend']->logMessage($s, 'INFO');
    }

    public function getServerLocURI()
    {
        return $this->_targetLocURI;
    }

    public function getClientLocURI()
    {
        return $this->_sourceLocURI;
    }

    public function getClientAnchorNext()
    {
        return $this->_clientAnchorNext;
    }

    public function getServerAnchorNext()
    {
        return $this->_serverAnchorNext;
    }

    public function getServerAnchorLast()
    {
        return $this->_serverAnchorLast;
    }

    public function createUidMap($databaseURI, $cuid, $suid)
    {
        $device = $GLOBALS['backend']->state->getDevice();

        if ($GLOBALS['backend']->normalize($databaseURI) == 'calendar' &&
            $device->handleTasksInCalendar() &&
            isset($this->_server_task_adds[$suid])) {
            $db = $this->_taskToCalendar($GLOBALS['backend']->normalize($databaseURI));
        } else {
            $db = $databaseURI;
        }

        $GLOBALS['backend']->createUidMap($db, $cuid, $suid);
        $GLOBALS['backend']->logMessage(
            'Created map for client id ' . $cuid . ' and server id ' . $suid
            . ' in database ' . $db, 'DEBUG');
    }

    /**
     * Returns the client ID of server change identified by the change type
     * and server ID.
     *
     * @param string $change  The change type (add, replace, delete).
     * @param string $id      The object's server UID.
     *
     * @return string  The matching client ID or null if none found.
     */
    public function getServerChange($change, $id)
    {
        $property = '_server_' . $change . 's';
        return isset($this->{$property[$id]}) ? $this->{$property[$id]} : null;
    }

    /**
     * Sets the client ID of server change identified by the change type and
     * server ID.
     *
     * @param string $change  The change type (add, replace, delete).
     * @param string $sid     The object's server UID.
     * @param string $cid     The object's client UID.
     */
    public function setServerChange($change, $sid, $cid)
    {
        $property = '_server_' . $change . 's';
        $this->{$property[$sid]} = $cid;
    }

    /**
     * Unsets the server-client-map of server change identified by the change
     * type and server ID.
     *
     * @param string $change  The change type (add, replace, delete).
     * @param string $id      The object's server UID.
     */
    public function unsetServerChange($change, $id)
    {
        $property = '_server_' . $change . 's';
        unset($this->{$property[$id]});
    }

    /**
     * Converts a calendar databaseURI to a tasks databaseURI for devices with
     * handleTasksInCalendar.
     */
    protected function _taskToCalendar($databaseURI)
    {
        return str_replace('calendar', 'tasks', $databaseURI);
    }
    
    /**
     * Get the next chunk from the cached large object with maximum length
     * of chunkLength or return false.
     *
     * @param int $chunkLength The maximum length of the chunk.
     *
     * @return string  The next chunk of the cached large object.
     */
    protected function _getServerLargeObjChunk($chunkLength)
    {
        if ($this->_server_largeobj) {
            $chunkContent = $this->_server_largeobj[1];
            if ($chunkLength < strlen($chunkContent)) {
                /* Ensure that no whitespace is on split edge, because it would get trimmed later during sending and size calculation would be wrong. */
                if (preg_match('/\A.+\S\S/s', substr($chunkContent, 0, $chunkLength + 1), $matches)) {
                    $this->_server_largeobj[1] = substr($chunkContent, strlen($matches[0]) - 1);
                    $chunkContent = substr($matches[0], 0, -1);
                } else {
                    /* Error: no fitting chunk found; return empty string to finish partial sending. */
                    $chunkContent = '';
                    unset($this->_server_largeobj);
                }                       
            } else {
                unset($this->_server_largeobj);
            }
            return $chunkContent;
        } else {
            return false;
        } 
    }
}
