<?php

namespace Doctrine\MongoDB;

/**
 * Wrapper for the PHP MongoCursor class.
 *
 * @since  1.0
 * @author Jonathan H. Wage <jonwage@gmail.com>
 */
class Cursor implements CursorInterface
{
    /**
     * The Collection instance used for recreating this cursor.
     *
     * This is also used to access the Connection for reinitializing during
     * retry attempts.
     *
     * @var Collection
     */
    protected $collection;

    /**
     * The MongoCursor instance being wrapped.
     *
     * @var \MongoCursor
     */
    protected $mongoCursor;

    /**
     * Number of times to retry queries.
     *
     * @var integer
     */
    protected $numRetries;

    /**
     * Whether to use the document's "_id" value as its iteration key.
     *
     * If false, the position of the document in the result set will be reported
     * instead. This is useful for documents that have non-scalar IDs.
     *
     * @var boolean
     */
    protected $useIdentifierKeys = true;

    protected $query = [];
    protected $fields = [];
    protected $hint;
    protected $immortal;
    protected $options = [];
    protected $batchSize;
    protected $limit;
    protected $maxTimeMS;
    protected $readPreference;
    protected $readPreferenceTags;
    protected $skip;
    protected $slaveOkay;
    protected $snapshot;
    protected $sort;
    protected $tailable;
    protected $timeout;

    /**
     * Constructor.
     *
     * The wrapped MongoCursor instance may change if the cursor is recreated.
     *
     * @param Collection   $collection  Collection used to create this Cursor
     * @param \MongoCursor $mongoCursor MongoCursor instance being wrapped
     * @param array        $query       Query criteria
     * @param array        $fields      Selected fields (projection)
     * @param integer      $numRetries  Number of times to retry queries
     */
    public function __construct(Collection $collection, \MongoCursor $mongoCursor, array $query = [], array $fields = [], $numRetries = 0)
    {
        $this->collection = $collection;
        $this->mongoCursor = $mongoCursor;
        $this->query = $query;
        $this->fields = $fields;
        $this->numRetries = (integer) $numRetries;
    }

    /**
     * Wrapper method for MongoCursor::addOption().
     *
     * @see http://php.net/manual/en/mongocursor.addoption.php
     * @param string $key
     * @param mixed $value
     * @return $this
     */
    public function addOption($key, $value)
    {
        $this->options[$key] = $value;
        $this->mongoCursor->addOption($key, $value);
        return $this;
    }

    /**
     * Wrapper method for MongoCursor::batchSize().
     *
     * @see http://php.net/manual/en/mongocursor.batchsize.php
     * @param integer $num
     * @return $this
     */
    public function batchSize($num)
    {
        $num = (integer) $num;
        $this->batchSize = $num;
        $this->mongoCursor->batchSize($num);
        return $this;
    }

    /**
     * Wrapper method for MongoCursor::count().
     *
     * @see http://php.net/manual/en/countable.count.php
     * @see http://php.net/manual/en/mongocursor.count.php
     * @param boolean $foundOnly
     * @return integer
     */
    public function count($foundOnly = false)
    {
        $cursor = $this;
        return $this->retry(function() use ($cursor, $foundOnly) {
            return $cursor->getMongoCursor()->count($foundOnly);
        }, true);
    }

    /**
     * Wrapper method for MongoCursor::current().
     *
     * @see http://php.net/manual/en/iterator.current.php
     * @see http://php.net/manual/en/mongocursor.current.php
     * @return array|null
     */
    public function current()
    {
        $current = $this->mongoCursor->current();
        if ($current instanceof \MongoGridFSFile) {
            $document = $current->file;
            $document['file'] = new GridFSFile($current);
            $current = $document;
        }
        return $current;
    }

    /**
     * Wrapper method for MongoCursor::dead().
     *
     * @see http://php.net/manual/en/mongocursor.dead.php
     * @return boolean
     */
    public function dead()
    {
        return $this->mongoCursor->dead();
    }

    /**
     * Wrapper method for MongoCursor::explain().
     *
     * @see http://php.net/manual/en/mongocursor.explain.php
     * @return array
     */
    public function explain()
    {
        $cursor = $this;
        return $this->retry(function() use ($cursor) {
            return $cursor->getMongoCursor()->explain();
        }, true);
    }

    /**
     * Wrapper method for MongoCursor::fields().
     *
     * @param array $f Fields to return (or not return).
     *
     * @see http://php.net/manual/en/mongocursor.fields.php
     * @return $this
     */
    public function fields(array $f)
    {
        $this->fields = $f;
        $this->mongoCursor->fields($f);
        return $this;
    }

    /**
     * Return the collection for this cursor.
     *
     * @return Collection
     */
    public function getCollection()
    {
        return $this->collection;
    }

    /**
     * Return the connection for this cursor.
     *
     * @deprecated 1.1 Will be removed for 2.0
     * @return Connection
     */
    public function getConnection()
    {
        return $this->collection->getDatabase()->getConnection();
    }

    /**
     * Return the selected fields (projection).
     *
     * @return array
     */
    public function getFields()
    {
        return $this->fields;
    }

    /**
     * Returns the MongoCursor instance being wrapped.
     *
     * @return \MongoCursor
     */
    public function getMongoCursor()
    {
        return $this->mongoCursor;
    }

    /**
     * Wrapper method for MongoCursor::getNext().
     *
     * @see http://php.net/manual/en/mongocursor.getnext.php
     * @return array|null
     */
    public function getNext()
    {
        $cursor = $this;
        $next = $this->retry(function() use ($cursor) {
            return $cursor->getMongoCursor()->getNext();
        }, false);
        if ($next instanceof \MongoGridFSFile) {
            $document = $next->file;
            $document['file'] = new GridFSFile($next);
            $next = $document;
        }
        return $next;
    }

    /**
     * Return the query criteria.
     *
     * @return array
     */
    public function getQuery()
    {
        return $this->query;
    }

    /**
     * Wrapper method for MongoCursor::getReadPreference().
     *
     * @see http://php.net/manual/en/mongocursor.getreadpreference.php
     * @return array
     */
    public function getReadPreference()
    {
        return $this->mongoCursor->getReadPreference();
    }

    /**
     * Set the read preference.
     *
     * @see http://php.net/manual/en/mongocursor.setreadpreference.php
     * @param string $readPreference
     * @param array  $tags
     * @return $this
     */
    public function setReadPreference($readPreference, array $tags = null)
    {
        if ($tags !== null) {
            $this->mongoCursor->setReadPreference($readPreference, $tags);
        } else {
            $this->mongoCursor->setReadPreference($readPreference);
        }

        $this->readPreference = $readPreference;
        $this->readPreferenceTags = $tags;

        return $this;
    }

    /**
     * Reset the cursor and return its first result.
     *
     * The cursor will be reset both before and after the single result is
     * fetched. The original cursor limit (if any) will remain in place.
     *
     * @see Iterator::getSingleResult()
     * @return array|object|null
     */
    public function getSingleResult()
    {
        $originalLimit = $this->limit;
        $originalUseIdentifierKeys = $this->useIdentifierKeys;

        $this->reset();
        $this->limit(1);
        $this->setUseIdentifierKeys(false);

        $result = current($this->toArray()) ?: null;

        $this->reset();
        $this->limit($originalLimit);
        $this->setUseIdentifierKeys($originalUseIdentifierKeys);

        return $result;
    }

    /**
     * Return whether the document's "_id" value is used as its iteration key.
     *
     * @since 1.2
     * @return boolean
     */
    public function getUseIdentifierKeys()
    {
        return $this->useIdentifierKeys;
    }

    /**
     * Set whether to use the document's "_id" value as its iteration key.
     *
     * @since 1.2
     * @param boolean $useIdentifierKeys
     * @return $this
     */
    public function setUseIdentifierKeys($useIdentifierKeys)
    {
        $this->useIdentifierKeys = (boolean) $useIdentifierKeys;

        return $this;
    }

    /**
     * Wrapper method for MongoCursor::hasNext().
     *
     * @see http://php.net/manual/en/mongocursor.hasnext.php
     * @return boolean
     */
    public function hasNext()
    {
        $cursor = $this;
        return $this->retry(function() use ($cursor) {
            return $cursor->getMongoCursor()->hasNext();
        }, false);
    }

    /**
     * Wrapper method for MongoCursor::hint().
     *
     * @see http://php.net/manual/en/mongocursor.hint.php
     * @param array|string $keyPattern
     * @return $this
     */
    public function hint($keyPattern)
    {
        $this->hint = $keyPattern;
        $this->mongoCursor->hint($keyPattern);
        return $this;
    }

    /**
     * Wrapper method for MongoCursor::immortal().
     *
     * @see http://php.net/manual/en/mongocursor.immortal.php
     * @param boolean $liveForever
     * @return $this
     */
    public function immortal($liveForever = true)
    {
        $liveForever = (boolean) $liveForever;
        $this->immortal = $liveForever;
        $this->mongoCursor->immortal($liveForever);
        return $this;
    }

    /**
     * Wrapper method for MongoCursor::info().
     *
     * @see http://php.net/manual/en/mongocursor.info.php
     * @return array
     */
    public function info()
    {
        return $this->mongoCursor->info();
    }

    /**
     * Wrapper method for MongoCursor::key().
     *
     * @see http://php.net/manual/en/iterator.key.php
     * @see http://php.net/manual/en/mongocursor.key.php
     * @return mixed
     */
    public function key()
    {
        // TODO: Track position internally to avoid repeated info() calls
        if ( ! $this->useIdentifierKeys) {
            $info = $this->mongoCursor->info();

            return isset($info['at']) ? $info['at'] : null;
        }

        return $this->mongoCursor->key();
    }

    /**
     * Wrapper method for MongoCursor::limit().
     *
     * @see http://php.net/manual/en/mongocursor.limit.php
     * @param integer $num
     * @return $this
     */
    public function limit($num)
    {
        $num = (integer) $num;
        $this->limit = $num;
        $this->mongoCursor->limit($num);
        return $this;
    }

    /**
     * Wrapper method for MongoCursor::maxTimeMS().
     *
     * @see http://php.net/manual/en/mongocursor.maxtimems.php
     * @param integer $ms
     * @return $this
     */
    public function maxTimeMS($ms)
    {
        $this->maxTimeMS = (integer) $ms;
        $this->mongoCursor->maxTimeMS($this->maxTimeMS);
        return $this;
    }

    /**
     * Wrapper method for MongoCursor::next().
     *
     * @see http://php.net/manual/en/iterator.next.php
     * @see http://php.net/manual/en/mongocursor.next.php
     */
    public function next()
    {
        $cursor = $this;
        $this->retry(function() use ($cursor) {
            $cursor->getMongoCursor()->next();
        }, false);
    }

    /**
     * Recreates the internal MongoCursor.
     */
    public function recreate()
    {
        $this->mongoCursor = $this->collection->getMongoCollection()->find($this->query, $this->fields);
        if ($this->hint !== null) {
            $this->mongoCursor->hint($this->hint);
        }
        if ($this->immortal !== null) {
            $this->mongoCursor->immortal($this->immortal);
        }
        foreach ($this->options as $key => $value) {
            $this->mongoCursor->addOption($key, $value);
        }
        if ($this->batchSize !== null) {
            $this->mongoCursor->batchSize($this->batchSize);
        }
        if ($this->limit !== null) {
            $this->mongoCursor->limit($this->limit);
        }
        if ($this->maxTimeMS !== null) {
            $this->mongoCursor->maxTimeMS($this->maxTimeMS);
        }
        if ($this->skip !== null) {
            $this->mongoCursor->skip($this->skip);
        }
        if ($this->slaveOkay !== null) {
            $this->setMongoCursorSlaveOkay($this->slaveOkay);
        }
        // Set read preferences after slaveOkay, since they may be more specific
        if ($this->readPreference !== null) {
            if ($this->readPreferenceTags !== null) {
                $this->mongoCursor->setReadPreference($this->readPreference, $this->readPreferenceTags);
            } else {
                $this->mongoCursor->setReadPreference($this->readPreference);
            }
        }
        if ($this->snapshot) {
            $this->mongoCursor->snapshot();
        }
        if ($this->sort !== null) {
            $this->mongoCursor->sort($this->sort);
        }
        if ($this->tailable !== null) {
            $this->mongoCursor->tailable($this->tailable);
        }
        if ($this->timeout !== null) {
            $this->mongoCursor->timeout($this->timeout);
        }
    }

    /**
     * Wrapper method for MongoCursor::reset().
     *
     * @see http://php.net/manual/en/iterator.reset.php
     * @see http://php.net/manual/en/mongocursor.reset.php
     */
    public function reset()
    {
        $this->mongoCursor->reset();
    }

    /**
     * Wrapper method for MongoCursor::rewind().
     *
     * @see http://php.net/manual/en/iterator.rewind.php
     * @see http://php.net/manual/en/mongocursor.rewind.php
     */
    public function rewind()
    {
        $cursor = $this;
        $this->retry(function() use ($cursor) {
            $cursor->getMongoCursor()->rewind();
        }, false);
    }

    /**
     * Set whether secondary read queries are allowed for this cursor.
     *
     * This method wraps setSlaveOkay() for driver versions before 1.3.0. For
     * newer drivers, this method either wraps setReadPreference() method and
     * specifies SECONDARY_PREFERRED or does nothing, depending on whether
     * setReadPreference() exists.
     *
     * @param boolean $ok
     */
    public function setMongoCursorSlaveOkay($ok)
    {
        if ($ok) {
            // Preserve existing tags for non-primary read preferences
            $readPref = $this->mongoCursor->getReadPreference();
            $this->mongoCursor->setReadPreference(\MongoClient::RP_SECONDARY_PREFERRED, isset($readPref['tagsets']) ? $readPref['tagsets'] : []);
        } else {
            $this->mongoCursor->setReadPreference(\MongoClient::RP_PRIMARY);
        }
    }

    /**
     * Wrapper method for MongoCursor::skip().
     *
     * @see http://php.net/manual/en/mongocursor.skip.php
     * @param integer $num
     * @return $this
     */
    public function skip($num)
    {
        $num = (integer) $num;
        $this->skip = $num;
        $this->mongoCursor->skip($num);
        return $this;
    }

    /**
     * Wrapper method for MongoCursor::slaveOkay().
     *
     * @see http://php.net/manual/en/mongocursor.slaveokay.php
     * @param boolean $ok
     * @return $this
     */
    public function slaveOkay($ok = true)
    {
        $ok = (boolean) $ok;
        $this->slaveOkay = $ok;
        $this->setMongoCursorSlaveOkay($ok);
        return $this;
    }

    /**
     * Wrapper method for MongoCursor::snapshot().
     *
     * @see http://php.net/manual/en/mongocursor.snapshot.php
     * @return $this
     */
    public function snapshot()
    {
        $this->snapshot = true;
        $this->mongoCursor->snapshot();
        return $this;
    }

    /**
     * Wrapper method for MongoCursor::sort().
     *
     * @see http://php.net/manual/en/mongocursor.sort.php
     * @param array $fields
     * @return $this
     */
    public function sort($fields)
    {
        foreach ($fields as $fieldName => $order) {
            if (is_string($order)) {
                $order = strtolower($order) === 'asc' ? 1 : -1;
            }

            if (is_scalar($order)) {
                $fields[$fieldName] = (integer) $order;
            }
        }
        $this->sort = $fields;
        $this->mongoCursor->sort($fields);
        return $this;
    }

    /**
     * Wrapper method for MongoCursor::tailable().
     *
     * @see http://php.net/manual/en/mongocursor.tailable.php
     * @param boolean $tail
     * @return $this
     */
    public function tailable($tail = true)
    {
        $tail = (boolean) $tail;
        $this->tailable = $tail;
        $this->mongoCursor->tailable($tail);
        return $this;
    }

    /**
     * Wrapper method for MongoCursor::timeout().
     *
     * @see http://php.net/manual/en/mongocursor.timeout.php
     * @param integer $ms
     * @return $this
     */
    public function timeout($ms)
    {
        $this->timeout = (integer) $ms;
        $this->mongoCursor->timeout($ms);
        return $this;
    }

    /**
     * Return the cursor's results as an array.
     *
     * @see Iterator::toArray()
     * @param boolean $useIdentifierKeys Deprecated since 1.2; will be removed in 2.0
     * @return array
     */
    public function toArray($useIdentifierKeys = null)
    {
        $originalUseIdentifierKeys = $this->useIdentifierKeys;
        $useIdentifierKeys = isset($useIdentifierKeys) ? (boolean) $useIdentifierKeys : $this->useIdentifierKeys;
        $cursor = $this;

        /* Let iterator_to_array() decide to use keys or not. This will avoid
         * superfluous MongoCursor::info() from the key() method until the
         * cursor position is tracked internally.
         */
        $this->useIdentifierKeys = true;

        $results = $this->retry(function() use ($cursor, $useIdentifierKeys) {
            return iterator_to_array($cursor, $useIdentifierKeys);
        }, true);

        $this->useIdentifierKeys = $originalUseIdentifierKeys;

        return $results;
    }

    /**
     * Wrapper method for MongoCursor::valid().
     *
     * @see http://php.net/manual/en/iterator.valid.php
     * @see http://php.net/manual/en/mongocursor.valid.php
     * @return boolean
     */
    public function valid()
    {
        return $this->mongoCursor->valid();
    }

    /**
     * Conditionally retry a closure if it yields an exception.
     *
     * If the closure does not return successfully within the configured number
     * of retries, its first exception will be thrown.
     *
     * The $recreate parameter may be used to recreate the MongoCursor between
     * retry attempts.
     *
     * @param \Closure $retry
     * @param boolean $recreate
     * @return mixed
     */
    protected function retry(\Closure $retry, $recreate = false)
    {
        if ($this->numRetries < 1) {
            return $retry();
        }

        $firstException = null;

        for ($i = 0; $i <= $this->numRetries; $i++) {
            try {
                return $retry();
            } catch (\MongoCursorException $e) {
            } catch (\MongoConnectionException $e) {
            }

            if ($firstException === null) {
                $firstException = $e;
            }
            if ($i === $this->numRetries) {
                throw $firstException;
            }
            if ($recreate) {
                $this->recreate();
            }
        }
    }
}
