<?php
/**
 * Horde_ActiveSync_Collections::
 *
 * @license   http://www.horde.org/licenses/gpl GPLv2
 *
 * @copyright 2010-2020 Horde LLC (http://www.horde.org)
 * @author    Michael J Rubinsky <mrubinsk@horde.org>
 * @package   ActiveSync
 */
/**
 * Horde_ActiveSync_Collections:: Responsible for all functionality related to
 * collections and managing the sync cache.
 *
 * @license   http://www.horde.org/licenses/gpl GPLv2
 *
 * @copyright 2010-2020 Horde LLC (http://www.horde.org)
 * @author    Michael J Rubinsky <mrubinsk@horde.org>
 * @package   ActiveSync
 * @internal  Not intended for use outside of the ActiveSync library.
 */
class Horde_ActiveSync_Collections implements IteratorAggregate
{
    const COLLECTION_ERR_FOLDERSYNC_REQUIRED = -1;
    const COLLECTION_ERR_SERVER              = -2;
    const COLLECTION_ERR_STALE               = -3;
    const COLLECTION_ERR_SYNC_REQUIRED       = -4;
    const COLLECTION_ERR_AUTHENTICATION      = -6;

    /**
     * The collection data
     *
     * @var array
     */
    protected $_collections = array();

    /**
     * Cache a temporary syncCache.
     *
     * @var Horde_ActiveSync_SyncCache
     */
    protected $_tempSyncCache;

    /**
     * The syncCache
     *
     * @var Horde_ActiveSync_SyncCache
     */
    protected $_cache;

    /**
     * The logger
     *
     * @var Horde_Log_Logger
     */
    protected $_logger;

    /**
     * Count of unchanged collections calculated for PARTIAL sync.
     *
     * @var integer
     */
    protected $_unchangedCount = 0;

    /**
     * Count of available synckeys
     *
     * @var integer
     */
    protected $_synckeyCount = 0;

    /**
     * Global WINDOWSIZE
     * Defaults to 100 (MS-ASCMD 2.2.3.188)
     *
     * @var integer
     */
    protected $_globalWindowSize = 100;

    /**
     * Flag to indicate we have overridden the globalWindowSize
     *
     * @var boolean
     */
    protected $_windowsizeOverride = false;

    /**
     * Imported changes flag.
     *
     * @var boolean
     */
    protected $_importedChanges = false;

    /**
     * Short sync request flag.
     *
     * @var boolean
     */
    protected $_shortSyncRequest = false;

    /**
     * Cache of collections that have had changes detected.
     *
     * @var array
     */
    protected $_changedCollections = array();

    /**
     * The ActiveSync server object.
     *
     * @var Horde_ActiveSync
     */
    protected $_as;

    /**
     * Cache the process id for logging.
     *
     * @var integer
     */
    protected $_procid;

    /**
     * Cache of changes.
     *
     * @var array
     */
    protected $_changes;

    /**
     * Flag to indicate the client is requesting a hanging SYNC.
     *
     * @var boolean
     */
    protected $_hangingSync = false;

    /**
     * Const'r
     *
     * @param Horde_ActiveSync_SyncCache $cache  The SyncCache.
     * @param Horde_ActiveSync $as               The ActiveSync server object.
     */
    public function __construct(
        Horde_ActiveSync_SyncCache $cache,
        Horde_ActiveSync $as)
    {

        $this->_cache = $cache;
        $this->_as = $as;
        $this->_logger = $as->logger;
        $this->_procid = getmypid();
    }

    /**
     * Load all the collections we know about from the cache.
     */
    public function loadCollectionsFromCache()
    {
        foreach ($this->_cache->getCollections(false) as $collection) {
            if (empty($collection['synckey']) && !empty($collection['lastsynckey'])) {
                $collection['synckey'] = $collection['lastsynckey'];
            }
            // Load the class if needed for EAS >= 12.1
            if (empty($collection['class'])) {
                $collection['class'] = $this->getCollectionClass($collection['id']);
            }
            if (empty($collection['serverid'])) {
                try {
                    $collection['serverid'] = $this->getBackendIdForFolderUid($collection['id']);
                } catch (Horde_ActiveSync_Exception $e) {
                    $this->_logger->err($e->getMessage());
                    continue;
                }
            }
            $this->_collections[$collection['id']] = $collection;
            $this->_logger->meta(sprintf(
                'COLLECTIONS: Loaded %s from the cache.',
                $collection['serverid'])
            );
        }
    }

    /**
     * Magic...
     */
    public function __call($method, $parameters)
    {
        switch ($method) {
        case 'hasPingChangeFlag':
        case 'addConfirmedKey':
        case 'updateCollection':
        case 'collectionExists':
        case 'updateWindowSize':
            return call_user_func_array(array($this->_cache, $method), $parameters);
        }

        throw new BadMethodCallException('Unknown method: ' . $method);
    }

    /**
     * Property getter
     */
    public function __get($property)
    {
        switch ($property) {
        case 'hbinterval':
        case 'wait':
        case 'confirmed_synckeys':
        case 'lasthbsyncstarted':
        case 'lastsyncendnormal':
            return $this->_cache->$property;
        case 'importedChanges':
        case 'shortSyncRequest':
        case 'hangingSync':
            $p = '_' . $property;
            return $this->$p;
        }

        throw new InvalidArgumentException('Unknown property: ' . $property);
    }

    /**
     * Property setter.
     */
    public function __set($property, $value)
    {
        switch ($property) {
        case 'importedChanges':
        case 'shortSyncRequest':
        case 'hangingSync':
            $p = '_' . $property;
            $this->$p = $value;
            return;
        case 'lasthbsyncstarted':
        case 'lastsyncendnormal':
        case 'hbinterval':
        case 'wait':
            $this->_cache->$property = $value;
            return;

        case 'confirmed_synckeys':
            throw new InvalidArgumentException($property . ' is READONLY.');
        }

        throw new InvalidArgumentException('Unknown property: ' . $property);
    }

    /**
     * Get a new collection array, populated with default values.
     *
     * @return array
     */
    public function getNewCollection()
    {
        return array(
            'clientids' => array(),
            'fetchids' => array(),
            'windowsize' => 100,
            'soft' => false,
            'conflict' => Horde_ActiveSync::CONFLICT_OVERWRITE_PIM
        );
    }

    /**
     * Ensure default OPTIONS values are populated, while not overwriting any
     * existing values.
     *
     * @since 2.20.0
     */
    public function ensureOptions()
    {
        foreach ($this->_collections as &$collection) {
            $this->_logger->meta(sprintf(
                'COLLECTIONS: Loading default OPTIONS for %s collection.',
                $collection['id'])
            );

            if (!isset($collection['mimesupport'])) {
                $collection['mimesupport'] = Horde_ActiveSync::MIME_SUPPORT_NONE;
            }
            if (!isset($collection['bodyprefs'])) {
                $collection['bodyprefs'] = array();
            }
        }
    }

    /**
     * Add a new populated collection array to the sync cache.
     *
     * @param array $collection        The collection array.
     * @param boolean $requireSyncKey  Attempt to read missing synckey from
     *                                 cache if true. If not found, set to 0.
     *
     * @throws  Horde_ActiveSync_Exception_StateGone  Thrown when no synckey
     *              is provided when one is specified as required, indicating
     *              the state on the client is possibly corrupt or when the
     *              serverid can not be found by the backend.
     */
    public function addCollection(array $collection, $requireSyncKey = false)
    {
        if ($requireSyncKey && empty($collection['synckey'])) {
            $cached_collections = $this->_cache->getCollections(false);
            $collection['synckey'] = !empty($cached_collections[$collection['id']])
                ? $cached_collections[$collection['id']]['lastsynckey']
                : 0;

            if ($collection['synckey'] === 0) {
                $this->_logger->err('COLLECTIONS: Attempting to add a collection
                    to the sync cache while requiring a synckey, but no
                    synckey could be found. Most likely a client error in
                    requesting a collection during PING before it has issued a
                    SYNC.'
                );
                throw new Horde_ActiveSync_Exception_StateGone(
                    'Synckey required in Horde_ActiveSync_Collections::addCollection, but none was found.'
                );
            }

            $this->_logger->meta(sprintf(
                'COLLECTIONS: Obtained synckey for collection %s from cache: %s',
                $collection['id'],
                $collection['synckey'])
            );
        }

        // Load the class if needed for EAS >= 12.1 and ensure we have the
        // backend folder id.
        if (empty($collection['class'])) {
            $collection['class'] = $this->getCollectionClass($collection['id']);
        }

        try {
            $collection['serverid'] = $this->getBackendIdForFolderUid($collection['id']);
        } catch (Horde_ActiveSync_Exception $e) {
            throw new Horde_ActiveSync_Exception_StateGone($e->getMessage());
        }

        $this->_collections[$collection['id']] = $collection;
        $this->_logger->meta(sprintf(
            'COLLECTIONS: Collection added to collection handler: collection: %s, synckey: %s.',
            $collection['serverid'],
            !empty($collection['synckey']) ? $collection['synckey'] : 'NONE')
        );
    }

    /**
     * Translate an EAS folder uid into a backend serverid.
     *
     * @param $id  The uid.
     *
     * @return string  The backend server id.
     * @throws Horde_ActiveSync_Exception_FolderGone
     */
    public function getBackendIdForFolderUid($folderid)
    {
        // Always use RI for recipient cache.
        if ($folderid == 'RI') {
            return $folderid;
        }
        $folder = $this->_cache->getFolder($folderid);
        if ($folder) {
            return $folder['serverid'];
        } else {
            $this->_logger->err('COLLECTIONS: Horde_ActiveSync_Collections::getBackendIdForFolderUid failed because folder was not found in cache.');
            throw new Horde_ActiveSync_Exception_FolderGone('Folder not found in cache.');
        }
    }

    /**
     * Translate a backend id E.g., INBOX into an EAS folder uid.
     *
     * @param string $folderid  The backend id.
     *
     * @return string The EAS uid.
     */
    public function getFolderUidForBackendId($folderid)
    {
        // Always use 'RI' for Recipient cache.
        if ($folderid == 'RI') {
            return $folderid;
        }
        $map = $this->_as->state->getFolderUidToBackendIdMap();
        if (empty($map[$folderid])) {
            return false;
        }

        return $map[$folderid];
    }

    /**
     * Return the count of available collections.
     *
     * @return integer
     */
    public function collectionCount()
    {
        return count($this->_collections);
    }

    /**
     * Return the count of collections in the cache only.
     *
     * @return integer
     */
    public function cachedCollectionCount()
    {
        return $this->_cache->countCollections();
    }

    /**
     * Set the getchanges flag on the specified collection.
     *
     * @param string $collection_id  The collection id.
     *
     * @throws Horde_ActiveSync_Exception
     */
    public function setGetChangesFlag($collection_id)
    {
        if (empty($this->_collections[$collection_id])) {
            throw new Horde_ActiveSync_Exception('Missing collection data');
        }
        $this->_collections[$collection_id]['getchanges'] = true;
    }

    /**
     * Get the getchanges flag on the specified collection.
     *
     * @param string $collection_id  The collection id.
     *
     * @return boolean
     * @throws Horde_ActiveSync_Exception
     */
    public function getChangesFlag($collection_id)
    {
        if (empty($this->_collections[$collection_id])) {
            throw new Horde_ActiveSync_Exception('Missing collection data');
        }
        return !empty($this->_collections[$collection_id]['getchanges']);
    }

    /**
     * Sets the default WINDOWSIZE.
     *
     * Note that this is really a ceiling on the number of TOTAL responses
     * that can be sent (including all collections). This method should be
     * renamed for 3.0
     *
     * @param integer $window    The windowsize
     * @param boolean $override  If true, this value will override any client
     *                           supplied value.
     */
    public function setDefaultWindowSize($window, $override = false)
    {
        if ($override) {
            $this->_windowsizeOverride = true;
        }

        if ($override || empty($this->_windowsizeOverride)) {
            $this->_globalWindowSize = $window;
        }
    }

    public function getDefaultWindowSize()
    {
        return $this->_globalWindowSize;
    }

    /**
     * Validates the collection data from the syncCache, filling in missing
     * values from the folder cache.
     */
    public function validateFromCache()
    {
        $this->_cache->validateCollectionsFromCache($this->_collections);
    }

    /**
     * Updates data from the cache for collectons that are already loaded. Used
     * to ensure looping SYNC and PING requests are operating on the most
     * recent syncKey.
     */
    public function updateCollectionsFromCache()
    {
        $this->_cache->refreshCollections();
        $collections = $this->_cache->getCollections();
        foreach (array_keys($this->_collections) as $id) {
            if (!empty($collections[$id])) {
                $this->_logger->meta(sprintf(
                    'COLLECTIONS: Refreshing %s from the cache.',
                    $id)
                );
                $this->_collections[$id] = $collections[$id];
            }
        }
    }

    /**
     * Return a collection class given the collection id.
     *
     * @param string $id  The collection id.
     *
     * @return string|boolean  The collection class or false if not found.
     */
    public function getCollectionClass($id)
    {
        if ($id == 'RI') {
            return $id;
        }

        // First try existing, loaded collections.
        if (!empty($this->_collections[$id])) {
            return $this->_collections[$id]['class'];
        }

        // Next look in the SyncCache.
        if (isset($this->_cache->folders[$id]['class'])) {
            $class = $this->_cache->folders[$id]['class'];
            $this->_logger->meta(sprintf(
                'COLLECTIONS: Obtaining collection class of %s for collection id %s',
                $class, $id)
            );
            return $class;
        }

        return false;
    }

    public function getCollectionType($id)
    {
        if ($id == 'RI') {
            return $id;
        }

        // First try existing, loaded collections.
        if (!empty($this->_collections[$id]['type'])) {
            return $this->_collections[$id]['type'];
        }

        // Next look in the SyncCache.
        if (isset($this->_cache->folders[$id]['type'])) {
            $type = $this->_cache->folders[$id]['type'];
            $this->_logger->meta(sprintf(
                'COLLECTIONS: Obtaining collection type of %s for collection id %s',
                $type, $id)
            );
            return $type;
        }

        return false;
    }

    /**
     * Determine if we have any syncable collections either locally or in the
     * sync cache.
     *
     * @param long $version  The EAS version
     *
     * @return boolean
     */
    public function haveSyncableCollections($version)
    {
        // Ensure we have syncable collections, using the cache if needed.
        if ($version >= Horde_ActiveSync::VERSION_TWELVEONE && empty($this->_collections)) {
            $this->_logger->meta('COLLECTIONS: No collections loaded, looking in sync_cache.');
            $found = false;
            foreach ($this->_cache->getCollections() as $value) {
                if (isset($value['synckey'])) {
                    $this->_logger->meta(sprintf(
                        'COLLECTIONS: Found syncable collection: %s : %s.',
                        $value['serverid'], $value['synckey'])
                    );
                    $this->_collections[$value['id']] = $value;
                    $found = true;
                }
            }
            return $found;
        } elseif (empty($this->_collections)) {
            return false;
        }

        $this->_logger->meta('COLLECTIONS: Have syncable collections!');

        return true;
    }

    /**
     * Set the looping sync heartbeat values.
     *
     * @param array $hb  An array containing one or both of: hbinterval, wait.
     */
    public function setHeartbeat(array $hb)
    {
        if (isset($hb['wait'])) {
            $this->_cache->wait = $hb['wait'];
        }
        if (isset($hb['hbinterval'])) {
            $this->_cache->hbinterval = $hb['hbinterval'];
        }
    }

    /**
     * Return the heartbeat interval. Always returned as the heartbeat (seconds)
     * not wait interval (minutes).
     *
     * @return integer|boolean  The number of seconds in a heartbeat, or false
     *                          if no heartbeat set.
     */
    public function getHeartbeat()
    {
        return !empty($this->_cache->hbinterval)
            ? $this->_cache->hbinterval
            : (!empty($this->_cache->wait)
                ? $this->_cache->wait * 60
                : false);
    }

    /**
     * Return whether or not we want a looping sync. We can do a looping sync
     * if we have no imported changes AND we have either a hbinterval, wait,
     * or a shortSync.
     *
     * @return boolean  True if we want a looping sync, false otherwise.
     */
    public function canDoLoopingSync()
    {
        return $this->_hangingSync && !$this->_importedChanges && ($this->_shortSyncRequest || $this->_cache->hbinterval !== false || $this->_cache->wait !== false);
    }

    /**
     * Return if the current looping sync is stale. A stale looping sync is one
     * which has begun earlier than the most recently running sync reported by
     * the syncCache.
     *
     * @return boolean  True if the current looping sync is stale. False
     *                  otherwise.
     */
    public function checkStaleRequest()
    {
        return !$this->_cache->validateCache(true);
    }

    /**
     * Return if we have a current folder hierarchy.
     *
     * @return boolean
     */
    public function haveHierarchy()
    {
        return isset($this->_cache->hierarchy);
    }

    /**
     * Prepare for a hierarchy sync.
     *
     * @param string $synckey  The current synckey from the client.
     *
     * @return array  An array of known folders.
     */
    public function initHierarchySync($synckey)
    {
        $this->_as->state->loadState(
            array(),
            $synckey,
            Horde_ActiveSync::REQUEST_TYPE_FOLDERSYNC);

        // Refresh the cache since it might have changed like e.g., if synckey
        // was empty.
        $this->_cache->loadCacheFromStorage();

        return $this->_as->state->getKnownFolders();
    }

    /**
     * Update/Add a folder in the hierarchy cache.
     *
     * @param Horde_ActiveSync_Message_Folder $folder  The folder object.
     * @param boolean $update  Update the state objects? @since 2.4.0
     */
    public function updateFolderinHierarchy(
        Horde_ActiveSync_Message_Folder $folder, $update = false)
    {
        $this->_cache->updateFolder($folder);
        $cols = $this->_cache->getCollections(false);
        $cols[$folder->serverid]['serverid'] = $folder->_serverid;
        $this->_cache->updateCollection($cols[$folder->serverid]);
        if ($update) {
            $this->_as->state->updateServerIdInState($folder->serverid, $folder->_serverid);
        }
    }

    /**
     * Delete a folder from the hierarchy cache.
     *
     * @param string $id  The folder's uid.
     */
    public function deleteFolderFromHierarchy($uid)
    {
        $this->_cache->deleteFolder($uid);
        $this->_as->state->removeState(array(
            'id' => $uid,
            'devId' => $this->_as->device->id,
            'user' => $this->_as->device->user));
    }

    /**
     * Return all know hierarchy changes.
     *
     * @return array  An array of changes.
     */
    public function getHierarchyChanges()
    {
        return $this->_as->state->getChanges();
    }

    /**
     * Validate and perform some sanity checks on the hierarchy changes before
     * being sent to the client.
     *
     * @param Horde_ActiveSync_Connector_Exporter_FolderSync $exporter  The exporter.
     * @param array $seenFolders                             An array of folders.
     */
    public function validateHierarchyChanges(Horde_ActiveSync_Connector_Exporter_FolderSync $exporter, array $seenFolders)
    {
        if ($this->_as->device->version < Horde_ActiveSync::VERSION_TWELVEONE ||
            count($exporter->changed)) {
            return;
        }

        // Remove unnecessary changes.
        foreach ($exporter->changed as $key => $folder) {
            if (isset($folder->serverid) &&
                $syncFolder = $this->_cache->getFolder($folder->serverid) &&
                in_array($folder->serverid, $seenfolders) &&
                $syncFolder['parentid'] == $folder->parentid &&
                $syncFolder['displayname'] == $folder->displayname &&
                $syncFolder['type'] == $folder->type) {

                $this->_logger->meta(sprintf(
                    'COLLECTIONS: Ignoring %s from changes because it contains no changes from device.',
                    $folder->serverid)
                );
                unset($exporter->changed[$key]);
                $exporter->count--;
            }
        }

        // Remove unnecessary deletions.
        foreach ($exporter->deleted as $key => $folder) {
            if (($sid = array_search($folder, $seenfolders)) === false) {
                $this->_logger->meta(sprintf(
                    'COLLECTIONS: Ignoring %s from deleted list because the device does not know it',
                    $folder)
                );
                unset($exporter->deleted[$key]);
                $exporter->count--;
            }
        }
    }

    /**
     * Update the hierarchy synckey in the cache.
     *
     * @param string $key  The new/existing synckey.
     */
    public function updateHierarchyKey($key)
    {
        $this->_cache->hierarchy = $key;
    }

    /**
     * Prepares the syncCache for a full sync request.
     */
    public function initFullSync()
    {
        $this->_cache->confirmed_synckeys = array();
        $this->_cache->clearCollectionKeys();
    }

    /**
     * Prepare the syncCache for an EMPTY sync request.
     *
     * @return boolean  False if EMPTY request cannot be performed, otherwise
     *                  true.
     * @since 2.25.0
     */
    public function initEmptySync()
    {
        $this->loadCollectionsFromCache();
        foreach ($this->_collections as $value) {
            // Remove keys from confirmed synckeys array and count them
            if (isset($value['synckey'])) {
                if (isset($this->_cache->confirmed_synckeys[$value['synckey']])) {
                    $this->_logger->meta(sprintf(
                        'COLLECTIONS: Removed %s from confirmed_synckeys',
                        $value['synckey'])
                    );
                    $this->_cache->removeConfirmedKey($value['synckey']);
                }
                $this->_synckeyCount++;
            }
        }
        if (!$this->_checkConfirmedKeys()) {
            $this->_logger->err('COLLECTIONS: Some synckeys were not confirmed, but handling an empty request. Requesting full SYNC');
            $this->save();
            return false;
        }
        $this->shortSyncRequest = true;
        $this->hangingSync = true;
        $this->save(true);

        return true;
    }

    /**
     * Prepares the syncCache for a partial sync request and checks that
     * it is allowed.
     *
     * MS-ASCMD 2.2.3.124
     *
     * @return boolean True if parital sync is possible, false otherwise.
     */
    public function initPartialSync()
    {
        // Need this for all PARTIAL sync requests.
        $this->_tempSyncCache = clone $this->_cache;

        // Short circuit if we only have a changed ping/wait interval.
        if (empty($this->_collections)) {
            $emptyCollections = true;
            $this->_logger->meta('COLLECTIONS: No collections loaded, loading full collection set from cache.');
            $this->loadCollectionsFromCache();

        } else {
            // Collect collection options sent from client and compare against
            // last known collection options to determine which collections
            // changed.
            $emptyCollections = false;
            $c = $this->_tempSyncCache->getCollections();
            foreach ($this->_collections as $key => $value) {
                // Collections from cache might not all have synckeys.
                if (empty($c[$key])) {
                    continue;
                }
                $v1 = $value;
                foreach ($v1 as $k => $o) {
                    if (is_null($o)) {
                        unset($v1[$k]);
                    }
                }
                unset($v1['id'], $v1['serverid'], $v1['clientids'], $v1['fetchids'],
                    $v1['getchanges'], $v1['changeids'], $v1['pingable'],
                    $v1['class'], $v1['synckey'], $v1['lastsynckey']
                );
                $v2 = $c[$key];
                foreach ($v2 as $k => $o) {
                    if (is_null($o)) {
                        unset($v2[$k]);
                    }
                }
                unset($v2['id'], $v2['serverid'], $v2['pingable'], $v2['class'],
                    $v2['synckey'], $v2['lastsynckey']
                );
                ksort($v1);
                if (isset($v1['bodyprefs'])) {
                    ksort($v1['bodyprefs']);
                    foreach (array_keys($v1['bodyprefs']) as $k) {
                        if (is_array($v1['bodyprefs'][$k])) {
                            ksort($v1['bodyprefs'][$k]);
                        }
                    }
                }
                ksort($v2);
                if (isset($v2['bodyprefs'])) {
                    ksort($v2['bodyprefs']);
                    foreach (array_keys($v2['bodyprefs']) as $k) {
                        if (is_array($v2['bodyprefs'][$k])) {
                            ksort($v2['bodyprefs'][$k]);
                        }
                    }
                }

                if (md5(serialize($v1)) == md5(serialize($v2))) {
                    $this->_unchangedCount++;
                }

                // Unset in tempSyncCache, since we have it from device.
                $this->_tempSyncCache->removeCollection($key);

                // Populate _collections with missing collection data not sent.
                $this->_getMissingCollectionsFromCache();
            }
        }

        // Ensure we are both talking about the same synckey.
        foreach ($this->_collections as $value) {
            if (isset($value['synckey'])) {
                if (isset($this->_cache->confirmed_synckeys[$value['synckey']])) {
                    $this->_logger->meta(sprintf(
                        'COLLECTIONS: Removed %s from confirmed_synckeys',
                        $value['synckey'])
                    );
                    $this->_cache->removeConfirmedKey($value['synckey']);
                }
                $this->_synckeyCount++;
            }
        }

        if (!$this->_checkConfirmedKeys()) {
            $this->_logger->warn('COLLECTIONS: Some synckeys were not confirmed. Requesting full SYNC');
            $this->save();
            return false;
        }

        if (!$emptyCollections && $this->_haveNoChangesInPartialSync()) {
            $this->_logger->warn('COLLECTIONS: Partial Request with completely unchanged collections. Request a full SYNC');
            return false;
        }

        return true;
    }

    protected function _checkConfirmedKeys()
    {
        $csk = $this->_cache->confirmed_synckeys;
        if ($csk) {
            $this->_logger->meta(sprintf(
                'COLLECTIONS: Confirmed Synckeys contains %s',
                serialize($csk))
            );
            return false;
        }

        return true;
    }

    /**
     * Return if we can do an empty response
     *
     * @return boolean
     */
    public function canSendEmptyResponse()
    {
        return !$this->_importedChanges &&
            ($this->_hangingSync && ($this->_cache->wait !== false || $this->_cache->hbinterval !== false));
    }

    /**
     * Return if we have no changes to collection options, but have requested
     * a partial sync. A partial sync must have either a wait, hbinterval,
     * or some subset of collections to be valid.
     *
     * @return boolean
     */
    protected function _haveNoChangesInPartialSync()
    {
        return $this->_synckeyCount > 0 &&
            $this->_unchangedCount == $this->_synckeyCount &&
            $this->_cache->wait == false && $this->_cache->hbinterval == false;
    }

    /**
     * Populate the collections data with missing data from the syncCache during
     * a PARTIAL SYNC.
     */
    protected function _getMissingCollectionsFromCache()
    {
        if (empty($this->_tempSyncCache)) {
            throw new Horde_ActiveSync_Exception('Did not initialize the PARTIAL sync.');
        }

        // Update _collections with all data that was not sent, but we
        // have a synckey for in the sync_cache.
        foreach ($this->_tempSyncCache->getCollections() as $value) {
            // The collection might have been updated due to incoming
            // changes. Some clients send COMMANDS in a PARTIAL sync and
            // initializing the PARTIAL afterwards will overwrite the various
            // flags stored in $collection['id'][]
            if (!empty($this->_collections[$value['id']])) {
                continue;
            }
            $this->_logger->meta(sprintf(
                'COLLECTIONS: Using SyncCache State for %s',
                $value['serverid']
            ));
            if (empty($value['synckey'])) {
                $value['synckey'] = $value['lastsynckey'];
            }
            $this->_collections[$value['id']] = $value;
        }
    }

    /**
     * Check for an update FILTERTYPE
     *
     * @param string $id      The collection id to check
     * @param string $filter  The new filter value.
     *
     * @return boolean  True if filtertype passed, false if it has changed.
     */
    public function checkFilterType($id, $filter)
    {
        $cc = $this->_cache->getCollections();
        if (!empty($cc[$id]['filtertype']) &&
            !is_null($filter) &&
            $cc[$id]['filtertype'] != $filter) {
            $this->_logger->meta(sprintf(
                'COLLECTIONS: Filtertype change from: %d to %d',
                $cc[$id]['filtertype'], $filter)
            );
            $this->_cache->updateFiltertype($id, $filter);
            return false;
        }

        return true;
    }

    /**
     * Update the syncCache with current collection data.
     */
    public function updateCache()
    {
        foreach ($this->_collections as $value) {
            $this->_cache->updateCollection($value);
        }
    }

    /**
     * Save the syncCache to storage.
     *
     * @param boolean $preserve_folders  If true, ensure the folder cache is not
     *                                   overwritten. @since 2.18.0
     * @todo Refactor this hack away. Requires a complete refactor of the cache.
     */
    public function save($preserve_folders = false)
    {
        // HOTFIX. Need to check the timestamp to see if we should reload the
        // folder cache before saving to ensure it isn't overwritten. See
        // Bug: 13273
        if ($preserve_folders && !$this->_cache->validateCache()) {
            $this->_logger->meta('COLLECTIONS: Updating the foldercache before saving.');
            $this->_cache->refreshFolderCache();
        }

        $this->_cache->save();
    }

    /**
     * Attempt to initialize the sync state.
     *
     * @param array $collection  The collection array.
     * @param boolean $requireSyncKey  Require collection to have a synckey and
     *                                 throw exception if it's not present.
     *
     * @throws Horde_ActiveSync_Exception_InvalidRequest
     * @throws Horde_ActiveSync_Exception_FolderGone
     */
    public function initCollectionState(array &$collection, $requireSyncKey = false)
    {
        // Clear the changes cache.
        $this->_changes = null;

        // Ensure we have a collection class.
        if (empty($collection['class'])) {
            if (!($collection['class'] = $this->getCollectionClass($collection['id']))) {
                throw new Horde_ActiveSync_Exception_FolderGone('Could not load collection class for ' . $collection['id']);
            }
        }

        // Load the collection's type if we can.
        if (empty($collection['type'])) {
            $collection['type'] = $this->getCollectionType($collection['id']);
        }

        // Get the backend serverid.
        if (empty($collection['serverid'])) {
            $collection['serverid'] = $this->getBackendIdForFolderUid($collection['id']);
        }


        if ($requireSyncKey && empty($collection['synckey'])) {
            throw new Horde_ActiveSync_Exception_InvalidRequest(sprintf(
                'Empty synckey for %s.',
                $collection['id'])
            );
        }

        // Initialize the state
        $this->_logger->info(sprintf(
            'COLLECTIONS: Initializing state for collection: %s, synckey: %s',
            $collection['serverid'],
            $collection['synckey'])
        );
        $this->_as->state->loadState(
            $collection,
            $collection['synckey'],
            Horde_ActiveSync::REQUEST_TYPE_SYNC,
            $collection['id']);
    }

    /**
     * Poll the backend for changes.
     *
     * @param integer $heartbeat  The heartbeat lifetime to wait for changes.
     * @param integer $interval   The wait interval between poll iterations.
     * @param array $options      An options array containing any of:
     *   - pingable: (boolean)  Only poll collections with the pingable flag set.
     *                DEFAULT: false
     *
     * @return boolean|integer True if changes were detected in any of the
     *                         collections, false if no changes detected
     *                         or a status code if failed.
     */
    public function pollForChanges($heartbeat, $interval, array $options = array())
    {
        $dataavailable = false;
        $started = time();
        $until = $started + $heartbeat;

        $this->_logger->info(sprintf(
            'COLLECTIONS: Waiting for changes for %s seconds',
            $heartbeat)
        );

        // If pinging, make sure we have pingable collections. Note we can't
        // filter on them here because the collections might change during the
        // loop below.
        if (!empty($options['pingable']) && !$this->havePingableCollections()) {
            $this->_logger->err('COLLECTIONS: No pingable collections.');
            return self::COLLECTION_ERR_SERVER;
        }

        // Need to update AND SAVE the timestamp for race conditions to be
        // detected.
        $this->lasthbsyncstarted = $started;
        $this->save();

        // We only check for remote wipe request once every 5 iterations to
        // save on DB load since we must reload the device's state each time.
        $rw_check_countdown = 5;
        while (($now = time()) < $until) {

            // Try not to go over the heartbeat interval.
            if ($until - $now < $interval) {
                $interval = $until - $now;
            }

            // See if another process has altered the sync_cache.
            if ($this->checkStaleRequest()) {
                return self::COLLECTION_ERR_STALE;
            }

            // Make sure the collections are still there (there might have been
            // an error in refreshing them from the cache). Ideally this should
            // NEVER happen.
            if (!count($this->_collections)) {
                $this->_logger->err('NO COLLECTIONS! This should not happen!');
                return self::COLLECTION_ERR_SERVER;
            }

            // Check for WIPE request once every 5 iterations to balance between
            // performance and speed of catching a remote wipe request.
            if ($rw_check_countdown-- == 0) {
                 $rw_check_countdown = 5;
                 if ($this->_as->provisioning != Horde_ActiveSync::PROVISIONING_NONE) {
                    $rwstatus = $this->_as->state->getDeviceRWStatus($this->_as->device->id, true);
                    if ($rwstatus == Horde_ActiveSync::RWSTATUS_PENDING || $rwstatus == Horde_ActiveSync::RWSTATUS_WIPED) {
                        return self::COLLECTION_ERR_FOLDERSYNC_REQUIRED;
                    }
                }
            }

            // Check each collection we are interested in.
            foreach ($this->_collections as $id => $collection) {

                // Initialize the collection's state data in the state handler.
                try {
                    $this->initCollectionState($collection, true);
                } catch (Horde_ActiveSync_Exception_StateGone $e) {
                    $this->_logger->notice(sprintf(
                        'COLLECTIONS: State not found for %s. Continuing by rquesting a SYNC.',
                        $id)
                    );
                    $dataavailable = true;
                    $this->setGetChangesFlag($id);
                    continue;
                } catch (Horde_ActiveSync_Exception_InvalidRequest $e) {
                    // Thrown when state is unable to be initialized because the
                    // collection has not yet been synched, but was requested to
                    // be pinged.
                    $this->_logger->err(sprintf(
                        'COLLECTIONS: Unable to initialize state for %s. Ignoring during pollForChanges: %s.',
                        $id,
                        $e->getMessage())
                    );
                    continue;
                } catch (Horde_ActiveSync_Exception_FolderGone $e) {
                    $this->_logger->warn('COLLECTIONS: Folder gone for collection ' . $collection['id']);
                    return self::COLLECTION_ERR_FOLDERSYNC_REQUIRED;
                } catch (Horde_ActiveSync_Exception $e) {
                    $this->_logger->err('COLLECTIONS: Error loading state: ' . $e->getMessage());
                    $this->_as->state->loadState(
                        array(),
                        null,
                        Horde_ActiveSync::REQUEST_TYPE_SYNC,
                        $id);
                    $this->setGetChangesFlag($id);
                    $dataavailable = true;
                    continue;
                }

                if (!empty($options['pingable']) && !$this->_cache->collectionIsPingable($id)) {
                    $this->_logger->notice(sprintf('COLLECTIONS: Skipping %s because it is not PINGable.', $id));
                    continue;
                }

                try {
                    if ($cnt = $this->getCollectionChangeCount(true)) {
                        $dataavailable = true;
                        $this->setGetChangesFlag($id);
                        if (!empty($options['pingable'])) {
                            $this->_cache->setPingChangeFlag($id);
                        }
                    } else {
                        try {
                            $this->_as->state->updateSyncStamp();
                        } catch (Horde_ActiveSync_Exception $e) {
                            $this->_logger->err($e->getMessage());
                        }
                    }
                } catch (Horde_ActiveSync_Exception_StaleState $e) {
                    $this->_logger->notice(sprintf(
                        'COLLECTIONS: SYNC terminating and force-clearing device state: %s',
                        $e->getMessage())
                    );
                    $this->_as->state->loadState(
                        array(),
                        null,
                        Horde_ActiveSync::REQUEST_TYPE_SYNC,
                        $id);
                    $this->setGetChangesFlag($id);
                    $dataavailable = true;
                } catch (Horde_ActiveSync_Exception_FolderGone $e) {
                    $this->_logger->notice(sprintf(
                        'COLLECTIONS: SYNC terminating: %s',
                        $e->getMessage())
                    );
                    // If we are missing a folder, we should clear the PING
                    // cache also, to be sure it picks up any hierarchy changes
                    // since most clients don't seem smart enough to figure this
                    // out on their own.
                    $this->resetPingCache();
                    return self::COLLECTION_ERR_FOLDERSYNC_REQUIRED;
                } catch (Horde_Exception_AuthenticationFailure $e) {
                    // We lost authentication for some reason.
                    $this->_logger->err('COLLECTIONS: Authentication lost during PING!!');
                    return self::COLLECTION_ERR_AUTHENTICATION;
                } catch (Horde_ActiveSync_Exception $e) {
                    $this->_logger->err(sprintf(
                        'COLLECTIONS: Sync object cannot be configured, throttling: %s',
                        $e->getMessage())
                    );
                    $this->_sleep(30);
                    continue;
                }
            }

            if (!empty($dataavailable)) {
                $this->_logger->info('COLLECTIONS: Found changes!');
                break;
            }

            // Wait a bit...
            $this->_sleep($interval);

            // Refresh the collections.
            $this->updateCollectionsFromCache();
        }

        // Check that no other Sync process already started
        // If so, we exit here and let the other process do the export.
        if ($this->checkStaleRequest()) {
            $this->_logger->meta('COLLECTIONS: Changes in cache determined during Sync Wait/Heartbeat, exiting here.');

            return self::COLLECTION_ERR_STALE;
        }

        $this->_logger->meta(sprintf(
            'COLLECTIONS: Looping Sync complete: DataAvailable: %s, DataImported: %s',
            $dataavailable,
            $this->importedChanges)
        );

        return $dataavailable;
    }

    /**
     * Wait for specified interval, and close any backend connections while
     * we wait.
     *
     * @param integer $interval  The number of seconds to sleep.
     */
    protected function _sleep($interval)
    {
        // Wait.
        $this->_logger->info(sprintf(
            '%sCOLLECTIONS: Sleeping for %s seconds.',
            str_repeat('-', 10),
            $interval));

        // Close any backend connections.
        $this->_cache->state->disconnect();
        sleep ($interval);
        $this->_cache->state->connect();
    }

    /**
     * Check if we have any pingable collections.
     *
     * @return boolean  True if we have collections marked as pingable.
     */
    public function havePingableCollections()
    {
        foreach (array_keys($this->_collections) as $id) {
            if ($this->_cache->collectionIsPingable($id)) {
                return true;
            }
        }

        return false;
    }

    /**
     * Marks all loaded collections with a synckey as pingable.
     */
    public function updatePingableFlag()
    {
        $collections = $this->_cache->getCollections(false);
        foreach ($collections as $id => $collection) {
            if (!empty($this->_collections[$id]['synckey'])) {
                $this->_logger->meta(sprintf(
                    'COLLECTIONS: Setting collection %s (%s) PINGABLE.',
                    $collection['serverid'],
                    $id)
                );
                $this->_cache->setPingableCollection($id);
            } else {
                $this->_logger->meta(sprintf(
                    'COLLECTIONS: UNSETTING collection %s (%s) PINGABLE flag.',
                    $collection['serverid'],
                    $id)
                );
                $this->_cache->removePingableCollection($id);
            }
        }
    }

    /**
     * Force reset all collection's PINGABLE flag. Used to force client
     * to issue a non-empty PING request.
     *
     */
    public function resetPingCache()
    {
        $collections = $this->_cache->getCollections(false);
        foreach ($collections as $id => $collection) {
            $this->_logger->meta(sprintf(
                'COLLECTIONS: UNSETTING collection %s (%s) PINGABLE flag.',
                $collection['serverid'],
                $id)
            );
            $this->_cache->removePingableCollection($id);
        }
    }

    /**
     * Return any changes for the current collection.
     *
     * @param boolean $ping  True if this is a PING request, false otherwise.
     *                       If true, we only detect that a change has occured,
     *                       not the data on all of the changes.
     * @param array $ensure  An array of UIDs that should be sent in the
     *                       current response if possible, and not put off
     *                       because of a MOREAVAILABLE situation.
     *                       @deprecated and no longer used.
     *
     * @return array  The changes array.
     */
    public function getCollectionChanges($ping = false, array $ensure = array())
    {
        if (empty($this->_changes)) {
            $this->_changes = $this->_as->state->getChanges(array('ping' => $ping));
        }

        return $this->_changes;
    }

    /**
     * Return the count of the current collection's chagnes.
     *
     * @param boolean $ping  Only ping the collection if true.
     *
     * @return integer  The change count.
     */
    public function getCollectionChangeCount($ping = false)
    {
        if (empty($this->_changes)) {
            $this->getCollectionChanges($ping);
        }

        return count($this->_changes);
    }

    /**
     * Iterator
     */
    public function getIterator()
    {
        return new ArrayIterator($this->_collections);
    }

}
