<?php
/**
 * Perforce Swarm
 *
 * @copyright   2013 Perforce Software. All rights reserved.
 * @license     Please see LICENSE.txt in top-level folder of this distribution.
 * @version     <release>/<patch>
 */

namespace Reviews\Model;

use P4\Connection\ConnectionInterface as Connection;
use P4\Connection\CommandResult;
use P4\Connection\Exception\CommandException;
use P4\Log\Logger;
use P4\OutputHandler\Limit;
use P4\Spec\Client;
use P4\Spec\Depot;
use P4\Spec\Exception\NotFoundException;
use P4\Spec\Change;
use P4\Uuid\Uuid;
use Users\Model\User;
use Projects\Filter\ProjectList as ProjectListFilter;
use Projects\Model\Project;
use Record\Exception\Exception;
use Record\Key\AbstractKey as KeyRecord;

/**
 * Provides persistent storage and indexing of reviews.
 */
class Review extends KeyRecord
{
    const KEY_PREFIX    = 'swarm-review-';
    const UPGRADE_LEVEL = 4;

    const LOCK_CHANGE_PREFIX = 'change-review-';

    const FETCH_BY_AUTHOR       = 'author';
    const FETCH_BY_CHANGE       = 'change';
    const FETCH_BY_PARTICIPANTS = 'participants';
    const FETCH_BY_HAS_REVIEWER = 'hasReviewer';
    const FETCH_BY_PROJECT      = 'project';
    const FETCH_BY_GROUP        = 'group';
    const FETCH_BY_STATE        = 'state';
    const FETCH_BY_TEST_STATUS  = 'testStatus';

    const STATE_NEEDS_REVIEW   = 'needsReview';
    const STATE_NEEDS_REVISION = 'needsRevision';
    const STATE_APPROVED       = 'approved';
    const STATE_REJECTED       = 'rejected';
    const STATE_ARCHIVED       = 'archived';

    const COMMIT_CREDIT_AUTHOR = 'creditAuthor';
    const COMMIT_DESCRIPTION   = 'description';
    const COMMIT_JOBS          = 'jobs';
    const COMMIT_FIX_STATUS    = 'fixStatus';

    const TEST_STATUS_PASS = 'pass';
    const TEST_STATUS_FAIL = 'fail';

    protected $userObjects = array();
    protected $fields      = array(
        'type'          => array(
            'accessor'      => 'getType',
            'readOnly'      => true
        ),
        'changes'       => array(       // changes associated with this review
            'index'         => 1301,
            'accessor'      => 'getChanges',
            'mutator'       => 'setChanges'
        ),
        'commits'       => array(
            'accessor'      => 'getCommits',
            'mutator'       => 'setCommits'
        ),
        'versions'      => array(
            'hidden'        => true,
            'accessor'      => 'getVersions',
            'mutator'       => 'setVersions'
        ),
        'author'        => array(       // author of code under review
            'index'         => 1302
        ),
        'participants'  => array(       // anyone who has touched the review (workflow change, commented on, etc.)
            'index'         => 1304,    // we return just user ids but properties (e.g. votes) are stored here too
            'indexOnlyKeys' => true,
            'accessor'      => 'getParticipants',
            'mutator'       => 'setParticipants'
        ),
        'participantsData' => array(
            'accessor'      => 'getParticipantsData',
            'mutator'       => 'setParticipantsData',
            'unstored'      => true
        ),
        'hasReviewer'   => array(       // flag to indicate if review has one or more reviewers
            'index'         => 1305     // necessary to avoid using wildcards in p4 search
        ),
        'description'   => array(       // change description
            'accessor'      => 'getDescription',
            'mutator'       => 'setDescription',
            'index'         => 1306,
            'indexWords'    => true
        ),
        'created',                      // timestamp when the review was created
        'updated',                      // timestamp when the review was last updated
        'projects'      => array(       // an array with project id's as keys and branches as values
            'index'         => 1307,
            'accessor'      => 'getProjects',
            'mutator'       => 'setProjects'
        ),
        'state'         => array(       // one of: needsReview, needsRevision, approved, rejected
            'index'         => 1308,
            'default'       => 'needsReview',
            'accessor'      => 'getState',
            'mutator'       => 'setState'
        ),
        'stateLabel'    => array(
            'accessor'      => 'getStateLabel',
            'unstored'      => true
        ),
        'testStatus'    => array(       // one of: pass, fail
            'index'         => 1309
        ),
        'testDetails'   => array(
            'accessor'      => 'getTestDetails',
            'mutator'       => 'setTestDetails'
        ),
        'deployStatus',                 // one of: success, fail
        'deployDetails' => array(
            'accessor'      => 'getDeployDetails',
            'mutator'       => 'setDeployDetails'
        ),
        'pending'       => array(
            'index'         => 1310,
            'accessor'      => 'isPending',
            'mutator'       => 'setPending'
        ),
        'commitStatus'  => array(
            'accessor'      => 'getCommitStatus',
            'mutator'       => 'setCommitStatus'
        ),
        'token'         => array(
            'accessor'      => 'getToken',
            'mutator'       => 'setToken',
            'hidden'        => true
        ),
        'upgrade'       => array(
            'accessor'      => 'getUpgrade',
            'hidden'        => true
        ),
        'groups'        => array(       // an array with associated groups
            'index'         => 1311,
            'accessor'      => 'getGroups',
            'mutator'       => 'setGroups'
        ),
    );

    /**
     * Retrieves all records that match the passed options.
     * Extends parent to compose a search query when fetching by various fields.
     *
     * @param   array       $options    an optional array of search conditions and/or options
     *                                  supported options are:
     *                                  FETCH_BY_CHANGE       - set to a 'changes' value(s) to limit results
     *                                  FETCH_BY_HAS_REVIEWER - set to limit results to include only records that:
     *                                                          * have at least one reviewer (if value is '1')
     *                                                          * don't have any reviewers   (if value is '0')
     *                                  FETCH_BY_STATE        - set to a 'state' value(s) to limit results
     *                                  FETCH_BY_TEST_STATUS  - set to a 'testStatus' values(s) to limit results
     * @param   Connection  $p4         the perforce connection to use
     * @return  \P4\Model\Fielded\Iterator   the list of zero or more matching review objects
     */
    public static function fetchAll(array $options, $p4)
    {
        // normalize options
        $options += array(
            static::FETCH_BY_AUTHOR       => null,
            static::FETCH_BY_CHANGE       => null,
            static::FETCH_BY_PARTICIPANTS => null,
            static::FETCH_BY_HAS_REVIEWER => null,
            static::FETCH_BY_PROJECT      => null,
            static::FETCH_BY_GROUP        => null,
            static::FETCH_BY_STATE        => null,
            static::FETCH_BY_TEST_STATUS  => null
        );

        // build the search expression
        $options[static::FETCH_SEARCH] = static::makeSearchExpression(
            array(
                'author'       => $options[static::FETCH_BY_AUTHOR],
                'changes'      => $options[static::FETCH_BY_CHANGE],
                'participants' => $options[static::FETCH_BY_PARTICIPANTS],
                'hasReviewer'  => $options[static::FETCH_BY_HAS_REVIEWER],
                'projects'     => $options[static::FETCH_BY_PROJECT],
                'groups'       => $options[static::FETCH_BY_GROUP],
                'state'        => $options[static::FETCH_BY_STATE],
                'testStatus'   => $options[static::FETCH_BY_TEST_STATUS]
            )
        );

        return parent::fetchAll($options, $p4);
    }

    /**
     * Return new review instance populated from the given change.
     *
     * @param   Change|string   $change     change to populate review record from
     * @param   Connection      $p4         the perforce connection to use
     * @return  Reviews         instance of this model
     */
    public static function createFromChange($change, $p4 = null)
    {
        if (!$change instanceof Change) {
            $change = Change::fetch($change, $p4);
        }

        // refuse to create reviews for un-promoted remote edge shelves
        if ($change->isRemoteEdgeShelf()) {
            throw new \InvalidArgumentException(
                "Cannot create review. The change is not promoted and appears to be on a remote edge server."
            );
        }

        // populate data from the change
        $model = new static($p4);
        $model->set('author',      $change->getUser());
        $model->set('description', $change->getDescription());
        $model->addParticipant($change->getUser());

        // add the change as either a pending or committed value
        if ($change->isSubmitted()) {
            $model->setPending(false);
            $model->addCommit($change);
            $model->addChange($change->getOriginalId());
        } else {
            $model->setPending(true);
            $model->addChange($change);
        }

        return $model;
    }

    /**
     * Updates this review record using the passed change.
     *
     * Add the change as a participant in this review and, if its pending,
     * updates the swarm managed shelf with the changes shelved content.
     *
     * @param   Change|string   $change                 change to populate review record from
     * @param   bool            $unapproveModified      whether approved reviews can be unapproved if they contain
     *                                                  modified files
     * @return  Review          instance of this model
     * @throws  \Exception      re-throws any exceptions which occur during re-shelving
     */
    public function updateFromChange($change, $unapproveModified = true)
    {
        // normalize change to an object
        $p4 = $this->getConnection();
        if (!$change instanceof Change) {
            $change = Change::fetch($change, $p4);
        }

        // our managed shelf should always be pending but defend anyways
        $shelf = Change::fetch($this->getId(), $p4);
        if ($shelf->isSubmitted()) {
            throw new Exception(
                'Cannot update review; the shelved change we manage is unexpectedly committed.'
            );
        }

        // add the passed change's id to the review
        if ($change->isSubmitted()) {
            // if we've already added a committed version for this change, nothing to do
            foreach ($this->getVersions() as $version) {
                if ($version['change'] == $change->getId() && !$version['pending']) {
                    return $this;
                }
            }

            $this->addCommit($change);
            $this->addChange($change->getOriginalId());
        } else {
            $this->addChange($change);
        }

        // ensure the change user is now a participant
        $this->addParticipant($change->getUser());

        // clear commit status if:
        // - this review isn't mid-commit (intended to clear old errors)
        // - this review is in the process of committing the given change
        if (!$this->isCommitting() || $this->getCommitStatus('change') == $change->getOriginalId()) {
            $this->setCommitStatus(null);
        }

        // try to determine the stream by inspecting the user's client
        // if the client is already deleted we're left with a hard false
        // indicating we couldn't tell one way or the other.
        $stream = false;
        try {
            $stream = Client::fetch($change->getClient())->getStream();
        } catch (\InvalidArgumentException $e) {
        } catch (NotFoundException $e) {
            // failed to get stream from client (client might be on an edge)
            // we don't consider this fatal and will try another approach
            unset($e);

            // try to determine the stream by looking at the path to the first file
            $stream = $this->guessStreamFromChange($change);
        }

        // we'll need a client for this next bit, we're going to update our shelved files
        $p4->getService('clients')->grab();
        try {
            // try and hard reset the client to ensure a clean environment
            $p4->getService('clients')->reset(true, $stream);

            // update metadata on the canonical shelf:
            //  - swap its client over to the one we grabbed
            //  - match type (public/restricted) of updating change
            //  - add any jobs from the updating change
            $shelf->setClient($p4->getClient())
                  ->setType($change->getType())
                  ->setJobs(array_unique(array_merge($change->getJobs(), $shelf->getJobs())))
                  ->setUser($p4->getUser())
                  ->save(true);

            // if the current contents of the canonical shelf are pending,
            // but not archived (as is the case for pre-versioning reviews),
            // attempt to archive it before we clobber it with this update.
            $head = end($this->getVersions());
            if ($head
                && $head['pending']
                && $head['change'] == $shelf->getId()
                && !isset($head['archiveChange'])
            ) {
                try {
                    $this->retroactiveArchive($shelf);
                } catch (\Exception $e) {
                    // well at least we tried!
                }
            }

            // evaluate whether the new version differs, we get back a flag indicating the amount of change:
            // 0 - no changes, 1 - modified name, type or digest, 2 - modified only insignificant fields
            $changesDiffer = $this->changesDiffer($shelf, $change);

            // revert state back to 'Needs Review' if auto state reversion is enabled, the review
            // was approved and the new version is different
            if ($this->getState() === static::STATE_APPROVED && $unapproveModified && $changesDiffer === 1) {
                $this->setState(static::STATE_NEEDS_REVIEW);
            }

            // if the contributing change is a commit:
            //  - empty out our shelf
            //  - add a version entry for the commit
            //  - clear our pending flag (review is committed now)
            if ($change->isSubmitted()) {
                // forcibly delete files in our shelf (in case another client has pending resolves)
                // silence the expected exceptions that occur if no shelved files were present
                // (e.g. user commits to a committed review) or files can't be deleted due to
                // pending resolves in another client (still an issue if server version <2014.2)
                try {
                    $p4->run('shelve', array('-d', '-f', '-c', $shelf->getId()));
                } catch (CommandException $e) {
                    if (preg_match('/needs resolve\sShelve aborted/', $e->getMessage())) {
                        Logger::log(Logger::ERR, $e);
                    } elseif (strpos($e->getMessage(), 'No shelved files in changelist to delete.') === false) {
                        throw $e;
                    }
                    unset($e);
                }

                // write a new version entry for this commit
                // only include the stream if we could determine its value
                $this->addVersion(
                    array(
                        'change'     => $change->getId(),
                        'user'       => $change->getUser(),
                        'time'       => $change->getTime(),
                        'pending'    => false,
                        'difference' => $changesDiffer
                    )
                    + ($stream !== false ? array('stream' => $stream) : array())
                );

                // at this point we have no shelved files, clear our isPending status
                $this->setPending(false);
            }

            // if the contributing change is pending and files have been updated:
            //  - unshelve it and check if that opened any files
            //  - bypass exclusive locks if supported, always check and throw if need be
            //  - update the canonical shelf with opened files
            //  - create a new archive/version for posterity
            //  - set the pending flag (review is not committed now)
            if ($change->isPending() && $changesDiffer) {
                $flags  = $this->canBypassLocks() ? array('--bypass-exclusive-lock') : array();
                $flags  = array_merge($flags, array('-s', $change->getId(), '-c', $shelf->getId()));
                $result = $p4->run('unshelve', $flags);
                $opened = array_filter($result->getData(), 'is_array') && !$result->hasWarnings();
                $this->exclusiveOpenCheck($result);
                if ($opened) {
                    // shelve opened files to the canonical shelf
                    $p4->run('shelve', array('-r', '-c', $shelf->getId()));

                    // we know we've shelved some files so update our 'pending' status
                    $this->setPending(true);

                    // make a new archive/version for this update
                    $this->archiveShelf(
                        $change,
                        array('difference' => $changesDiffer)
                        + ($stream !== false ? array('stream' => $stream) : array())
                    );

                    // we're done with the workspace files, be friendly and remove them
                    $p4->getService('clients')->clearFiles();
                }
            }
        } catch (\Exception $e) {
        }

        // send workspace files to Garbage Compactor 3263827 before releasing the client
        try {
            $p4->getService('clients')->clearFiles();
        } catch (\Exception $clearException) {
            // we're in a whole world of hurt right now, but let's log before the sweet release of death
            $message = 'Could not clear files on client: ' . $p4->getClient() . ' ' . $clearException->getMessage();
            Logger::log(Logger::ERR, $message);
        }

        $p4->getService('clients')->release();

        // if badnesses occurred re-throw now that we have released our client lock
        if (isset($e)) {
            throw $e;
        }

        return $this;
    }

    /**
     * Commits this review's pending work to perforce.
     *
     * You'll need to call 'update from change' after running this to have
     * the new change added to the review record.
     *
     * @param   array           $options        optional - currently supported options are:
     *                                            COMMIT_CREDIT_AUTHOR - credit change to review author
     *                                            COMMIT_DESCRIPTION   - change description
     *                                            COMMIT_JOBS          - list of jobs to attach to the committing change
     *                                            COMMIT_FIX_STATUS    - status to set on jobs upon commit
     * @param   Connection|null $p4             optional - connection to use for the submit or null for default
     *                                          it is recommend this be done as the user committing.
     * @return  Change          the submitted change object; useful for passing to update from change
     * @throws  Exception       if there are no pending files to commit
     * @throws  \Exception      re-throws any errors that occur during commit
     */
    public function commit(array $options = array(), Connection $p4 = null)
    {
        // normalize connection to use, we may have received null
        $p4 = $p4 ?: $this->getConnection();

        // normalize options
        $options += array(
            static::COMMIT_CREDIT_AUTHOR => null,
            static::COMMIT_DESCRIPTION   => null,
            static::COMMIT_JOBS          => null,
            static::COMMIT_FIX_STATUS    => null
        );

        // ensure commit status is set
        $this->setCommitStatus(array('start' => time()))->save();

        // we'll need a client for this next bit
        $p4->getService('clients')->grab();
        try {
            // try and hard reset the client to ensure a clean environment
            // if the change is against a stream; make sure we're on it
            $p4->getService('clients')->reset(true, $this->getHeadStream());

            // get the authoritative shelf, we need to examine if its restricted when creating the submit
            $shelf = Change::fetch($this->getId(), $p4);

            // create a new 'commit' change, we never commit the managed change
            // as we may later need to re-open this review.
            $commit = new Change($p4);
            $commit->setDescription($options[static::COMMIT_DESCRIPTION] ?: $this->get('description'))
                   ->setJobs($options[static::COMMIT_JOBS])
                   ->setFixStatus($options[static::COMMIT_FIX_STATUS])
                   ->setType($shelf->getType())
                   ->save();

            // update status with our change id, state and committer
            $this->setCommitStatus('change',    $commit->getId())
                 ->setCommitStatus('status',    'Unshelving')
                 ->setCommitStatus('committer', $p4->getUser())
                 ->save();

            // unshelve our managed change and check if that opened any files.
            // bypass exclusive locks if supported, always check and throw if need be.
            $flags  = $this->canBypassLocks() ? array('--bypass-exclusive-lock') : array();
            $flags  = array_merge($flags, array('-s', $this->getId(), '-c', $commit->getId()));
            $result = $p4->run('unshelve', $flags);
            $opened = $result->hasData() && !$result->hasWarnings();
            $this->exclusiveOpenCheck($result);

            // if we didn't unshelve any files blow up.
            if (!$opened) {
                throw new Exception(
                    "Review doesn't contain any files to commit."
                );
            }

            // we need to get the change id in as a commit early to
            // avoid having issues with double reporting activity.
            // also a good opportunity to update the state.
            $this->addCommit($commit->getId())
                 ->setCommitStatus('status', 'Committing')
                 ->save();

            // we must have unshelved some work, lets commit it.
            $commit->submit();

            $this->setCommitStatus('end', time())
                 ->setCommitStatus('status', 'Committed')
                 ->save();
        } catch (\Exception $e) {
            // if we got far enough to create the commit, remove it from the
            // list of 'commits' for this review as we didn't make it in.
            if (isset($commit) && $commit->getId()) {
                $this->setCommits(array_diff($this->getCommits(), array($commit->getId())));
                $this->setChanges(array_diff($this->getChanges(), array($commit->getId())));
            }

            // clear out the commit status but convey that we failed
            // we only use the first line of the exception as they get a bit too
            // detailed later on when not mergable.
            $this->setCommitStatus(array('error' => strtok($e->getMessage(), "\n")))
                 ->save();

            // as something went wrong we might be leaving files behind; cleanup
            $p4->getService('clients')->clearFiles();

            // delete the commit change we created; it's no longer needed
            // suppress exceptions without overwriting the one that got us here
            try {
                isset($commit) && $commit->delete();
            } catch (\Exception $ignore) {
            }
        }

        $p4->getService('clients')->release();

        // if badnesses occurred re-throw now that we have released our client lock
        if (isset($e)) {
            throw $e;
        }

        // if the credit author flag is set, re-own the change so the review creator gets credit
        if ($options[static::COMMIT_CREDIT_AUTHOR] && $p4->getUser() != $this->get('author')) {
            $p4Admin = $this->getConnection();
            $p4Admin->getService('clients')->grab();
            try {
                $commit->setConnection($p4Admin)
                       ->setUser($this->get('author'))
                       ->save(true);
            } catch (\Exception $e) {
                Logger::log(Logger::ERR, 'Failed to re-own change ' . $commit->getId() . ' to ' . $this->get('author'));
            }

            // ensure client gets released and we stop using the admin connection even if an exception occurred
            $p4Admin->getService('clients')->release();
            $commit->setConnection($p4);
        }

        return $commit;
    }

    /**
     * Returns the type of review we're dealing with.
     *
     * @return  string  the 'type' of this review, one of default or git
     */
    public function getType()
    {
        return $this->getRawValue('type') ?: 'default';
    }

    /**
     * Get the commit status for this code review
     *
     * @param   string|null     $field  a specific key to retrieve or null for all commit status
     *                                  if a field is specified which doesn't exist null is returned.
     * @return  string  Current state of this code review
     */
    public function getCommitStatus($field = null)
    {
        $status = (array) $this->getRawValue('commitStatus');

        // validate commit status
        // detect race-condition where commit-status is not empty, but the commit has been processed
        // if the commit is in changes and versions, we have processed it and status should be empty
        if (isset($status['change']) && in_array($status['change'], $this->getChanges())) {
            // extract commits from versions so we can look for the commit in question
            $commits = array();
            foreach ((array) $this->getRawValue('versions') as $version) {
                $version += array('change' => null, 'pending' => null);
                if (!$version['pending'] && $version['change'] >= $status['change']) {
                    $commits[] = '@=' . $version['change'];
                }
            }

            // if the commit was not renumbered the number could match exactly
            // if we don't get an exact match, we could still match the original id
            if ($commits && in_array('@=' . $status['change'], $commits)) {
                $status = array();
            } elseif ($commits) {
                try {
                    foreach ($this->getConnection()->run('changes', $commits)->getData() as $change) {
                        if (isset($change['oldChange']) && $status['change'] == $change['oldChange']) {
                            $status = array();
                        }
                    }
                } catch (\Exception $e) {
                    // not worth breaking things to possibly fix a race condition
                }
            }
        }

        if (!$field) {
            return $status;
        }

        return isset($status[$field]) ? $status[$field] : null;
    }

    /**
     * Set the commit status for this code review.
     *
     * @param   string|array    $fieldOrValues  a specific field name or an array of all new values
     * @param   mixed           $value          if a field was specified in param 1, the new value to use
     * @return  Review          to maintain a fluent interface
     */
    public function setCommitStatus($fieldOrValues, $value = null)
    {
        // if param 1 isn't a string it our new commit status
        if (!is_string($fieldOrValues)) {
            return $this->setRawValue('commitStatus', (array) $fieldOrValues);
        }

        // param 1 was a string, lets treat it as specific key to update
        $status                 = $this->getCommitStatus();
        $status[$fieldOrValues] = $value;
        return $this->setRawValue('commitStatus', $status);
    }

    /**
     * This method will determine if a commit is presently in progress based on the
     * data held in commit status.
     *
     * @return  bool    true if commit is actively in progress, false otherwise
     */
    public function isCommitting()
    {
        return $this->getCommitStatus() && !$this->getCommitStatus('error');
    }

    /**
     * Get the current state for this code review e.g. needsReview
     *
     * @return  string  Current state of this code review
     */
    public function getState()
    {
        return $this->getRawValue('state');
    }

    /**
     * Set the current state for this code review e.g. needsReview
     *
     * @param   string  $state  Current state of this code review
     * @return  Review          to maintain a fluent interface
     */
    public function setState($state)
    {
        // if we got approved:commit, simply store approved, the second
        // half is a queue to our caller that they aught to commit us.
        if ($state == 'approved:commit') {
            $state = 'approved';
        }

        return $this->setRawValue('state', $state);
    }

    /**
     * Get the participant data. Note the values are stored under the 'participants' field
     * but that accessor only exposes the IDs, this accessor exposes... _everything_.
     * The author is automatically included.
     *
     * User ids will be keys and each will have an array of properties associated to it
     * (such as vote, required, etc.).
     * If a specific 'field' is specified the user ids will be keys and each will have
     * just the specified property associated to it. Users lacking the specified field
     * will not be returned.
     *
     * @param   null|string     $field  optional - limit returned data to only 'field'; users lacking
     *                                  the specified field will not be included in the result.
     * @return  array   participant ids as keys each associated with properties array.
     */
    public function getParticipantsData($field = null)
    {
        // handle upgrade to v3 (2014.2)
        //  - numerically indexed user ids become arrays keyed on user id
        //  - votes move into participant array
        if ((int) $this->get('upgrade') < 3) {
            $participants = array();
            foreach ((array) $this->getRawValue('participants') as $key => $value) {
                if (is_string($value)) {
                    $key   = $value;
                    $value = array();
                }

                $participants[$key] = $value;
            }

            // move votes into participant metadata
            if ($this->issetRawValue('votes')) {
                // note we only honor votes for 'reviewers' if you are not a reviewer
                // your vote would have been ignored by getVotes and should be ignored here
                $author = $this->get('author');
                foreach ((array) $this->getRawValue('votes') as $user => $vote) {
                    if (isset($participants[$user]) && $user !== $author) {
                        $participants[$user] = array('vote' => $vote);
                    }
                }
                $this->unsetRawValue('votes');
            }

            $this->setRawValue('participants', $participants);
        }

        // handle upgrade to v4 (2014.3)
        // - single vote values become structured arrays with version info, e.g. [value => 1, version => 3]
        if ((int) $this->get('upgrade') < 4) {
            $participants = $this->getRawValue('participants');
            foreach ($participants as $user => $data) {
                if (isset($data['vote'])) {
                    $participants[$user]['vote'] = $this->normalizeVote($user, $data['vote'], true);
                    if (!$participants[$user]['vote']) {
                        unset($participants[$user]['vote']);
                    }
                }
            }

            $this->setRawValue('participants', $participants);
        }

        $participants = $this->normalizeParticipants($this->getRawValue('participants'));

        // if a specific field was specified, only include participants
        // that have that value and only include the one requested field
        if ($field) {
            foreach ($participants as $id => $data) {
                if (!isset($data[$field])) {
                    unset($participants[$id]);
                } else {
                    $participants[$id] = $data[$field];
                }
            }
        }

        return $participants;
    }

    /**
     * If only values is specified, updates all participant data.
     * In that usage values should appear similar to:
     *  $values => array('gnicol' => array(), 'slord' => array('required' => true))
     *
     * If both a values and field are specified, updates the specific property on the
     * participants array. Any participants not specified in the updated values array
     * will have the property removed if its already present. They will not be removed
     * as a participant though. We will then ensure a participate entry is present for
     * all specified users and that the value reflects what was passed.
     * In that usage values should appear similar to:
     *  $values => array('slord' => true), $field => 'required'
     *
     * @param   array|null      $values     the updated id/value(s) array
     * @param   null|string     $field      optional - a specific field we are updating (e.g. vote)
     * @return  Review  to maintain a fluent interface
     */
    public function setParticipantsData(array $values = null, $field = null)
    {
        // if no field was specified; we're updating everything just normalize, set, return
        if ($field === null) {
            return $this->setRawValue('participants', $this->normalizeParticipants($values, true));
        }

        // looks like we're just doing one specific field; make the update
        // first remove the specified field from all participants that are not listed
        $values       = (array) $values;
        $participants = $this->getParticipantsData();
        foreach (array_diff_key($participants, $values) as $id => $value) {
            unset($participants[$id][$field]);
        }

        // ensure a participant entry exists for all specified users and update value
        foreach ($values as $id => $value) {
            $participants             += array($id => array());
            $participants[$id][$field] = $value;
        }

        return $this->setRawValue('participants', $this->normalizeParticipants($participants, true));
    }

    /**
     * Update value(s) for a specific participant.
     *
     * If no field is specified, this clobbers the existing data for the given
     * participant with the new value.
     * If a field is specified, only the specific field is updated; any other
     * fields present on the participant are unchanged.
     *
     * @param   string  $user   the user we are setting data on
     * @param   mixed   $value  an array of all values (if no field was specified) otherwise the new value for $field
     * @param   mixed   $field  optional - if specified the specific field to update
     * @return  Review  to maintain a fluent interface
     */
    public function setParticipantData($user, $value, $field = null)
    {
        $participants  = $this->getParticipantsData();
        $participants += array($user => array());

        // if a specific field was specified; maintain all other properties
        if ($field) {
            $value = array($field => $value) + $participants[$user];
        }

        $participants[$user] = (array) $value;

        return $this->setParticipantsData($participants);
    }

    /**
     * Get list of participants associated with this review.
     * The current author is automatically included.
     *
     * @return  array   list of participants associated with this record
     */
    public function getParticipants()
    {
        return array_keys($this->getParticipantsData());
    }

    /**
     * Set participants associated with this review record.
     * If we have existing entries for any of the specified participants we will persist
     * their properties (e.g. votes) not throw them away.
     *
     * @param   string|array    $participants   list of participants
     * @return  Review          to maintain a fluent interface
     */
    public function setParticipants($participants)
    {
        $participants = array_filter((array) $participants);
        $participants = array_fill_keys($participants, array()) + array($this->get('author') => array());
        $participants = array_intersect_key($this->getParticipantsData(), $participants) + $participants;

        return $this->setRawValue('participants', $this->normalizeParticipants($participants, true));
    }

    /**
     * Get the description of this review.
     *
     * @return  string|null     the review's description
     */
    public function getDescription()
    {
        return $this->getRawValue('description');
    }

    /**
     * Set the description for this review.
     *
     * @param   string|null $description    the new description for this review
     * @return  Review          to maintain a fluent interface
     */
    public function setDescription($description)
    {
        return $this->setRawValue('description', $description);
    }

    /**
     * Get list of reviewers (all participants excluding the author).
     *
     * @return  array   list of reviewers associated with this record
     */
    public function getReviewers()
    {
        return array_values(array_diff($this->getParticipants(), array($this->get('author'))));
    }

    /**
     * Add one or more participants to this review record.
     *
     * @param   string|array    $participant    participant(s) to add
     * @return  Review          to maintain a fluent interface
     */
    public function addParticipant($participant)
    {
        return $this->setParticipants(
            array_merge($this->getParticipants(), (array) $participant)
        );
    }

    /**
     * Add one or more required reviewers to this review record.
     *
     * @param   string|array    $required    required reviewer(s) to add
     * @return  Review          to maintain a fluent interface
     */
    public function addRequired($required)
    {
        return $this->setParticipantsData(
            array_fill_keys(
                array_merge(
                    array_keys(array_filter($this->getParticipantsData('required'))),
                    (array) $required
                ),
                true
            ),
            'required'
        );
    }

    /**
     * Get list of votes (including stale votes)
     *
     * @return  array   list of votes left of this record
     */
    public function getVotes()
    {
        return $this->getParticipantsData('vote');
    }

    /**
     * Set votes on this review record
     *
     * @param   array   $votes   list of votes
     * @return  Review  to maintain a fluent interface
     */
    public function setVotes($votes)
    {
        return $this->setParticipantsData($votes, 'vote');
    }

    /**
     * This method is used to ensure arrays of changes always contain integers
     *
     * It will make an attempt to cast string integers to real integers,
     * it will detect Change objects and convert them to Change IDs,
     * and failures will be eliminated.
     *
     * @param   array   $changes    the array of Changes/IDs to be normalized
     * @return  array               the normalized array of Change IDs
     */
    protected function normalizeChanges($changes)
    {
        $changes = (array) $changes;

        foreach ($changes as $key => $change) {
            if ($change instanceof Change) {
                $change = $change->getId();
            }

            if (!ctype_digit((string) $change)) {
                unset($changes[$key]);
            } else {
                $changes[$key] = (int) $change;
            }
        }

        return array_values(array_unique($changes));
    }

    /**
     * Add a user's vote to this review record
     *
     * @param   string      $user       userid of the user to add
     * @param   int         $vote       vote (-1/0/1) to associate with the user
     * @param   int|null    $version    optional - version to add vote for
     *                                  defaults to current (head) version
     */
    public function addVote($user, $vote, $version = null)
    {
        $vote = array('value' => (int) $vote, 'version' => $version);
        return $this->setVotes(
            array_merge($this->getVotes(), array($user => $vote))
        );
    }

    /**
     * Returns a list of positive non-stale votes
     *
     * @return  array   list of votes
     */
    public function getUpVotes()
    {
        return array_filter(
            $this->getVotes(),
            function ($vote) {
                return $vote['value'] > 0 && !$vote['isStale'];
            }
        );
    }

    /**
     * Returns a list of negative non-stale votes
     *
     * @return  array   list of votes
     */
    public function getDownVotes()
    {
        return array_filter(
            $this->getVotes(),
            function ($vote) {
                return $vote['value'] < 0 && !$vote['isStale'];
            }
        );
    }

    /**
     * Get list of changes associated with this review.
     * This includes both pending and committed changes.
     *
     * @return  array   list of changes associated with this record
     */
    public function getChanges()
    {
        return $this->normalizeChanges($this->getRawValue('changes'));
    }

    /**
     * Set changes associated with this review record.
     *
     * @param   string|array    $changes    list of changes
     * @return  Review          to maintain a fluent interface
     */
    public function setChanges($changes)
    {
        return $this->setRawValue('changes', $this->normalizeChanges($changes));
    }

    /**
     * Add a change associated with this review record.
     *
     * @param   string  $change     the change to add
     * @return  Review  to maintain a fluent interface
     */
    public function addChange($change)
    {
        $changes   = $this->getChanges();
        $changes[] = $change;
        return $this->setChanges($changes);
    }

    /**
     * Get list of committed changes associated with this review.
     *
     * If a change contributes to this review and is later submitted
     * that won't automatically count. We only count changes which
     * were in a submitted state at the point they updated this review.
     *
     * @return  array   list of commits associated with this record
     */
    public function getCommits()
    {
        return $this->normalizeChanges($this->getRawValue('commits'));
    }

    /**
     * Set list of committed changes associated with this review.
     *
     * See @getCommits for details.
     *
     * @param   string|array    $changes    list of changes
     * @return  Review          to maintain a fluent interface
     */
    public function setCommits($changes)
    {
        $changes = $this->normalizeChanges($changes);

        // ensure all commits are also listed as being changes
        $this->setChanges(
            array_merge($this->getChanges(), $changes)
        );

        return $this->setRawValue('commits', $changes);
    }

    /**
     * Add a commit associated with this review record.
     *
     * @param   string  $change     the commit to add
     * @return  Review  to maintain a fluent interface
     */
    public function addCommit($change)
    {
        $changes   = $this->getCommits();
        $changes[] = $change;
        return $this->setCommits($changes);
    }

    /**
     * Get versions of this review (a version is created anytime files are updated).
     *
     * @return  array   a list of versions from oldest to newest
     *                  each version is an array containing change, user, time and pending
     */
    public function getVersions()
    {
        $versions = (array) $this->getRawValue('versions');

        // if there are no versions and this is an old record (level<2)
        // try fabricating versions from commits + current pending work
        // for pending work, we don't know who actually did it, so we
        // assume it was the review author.
        if (!$versions && $this->get('upgrade') < 2) {
            $versions = array();
            $changes  = array();
            if ($this->getCommits() || $this->isPending()) {
                $changes = $this->getCommits();
                sort($changes, SORT_NUMERIC);
                if ($this->isPending()) {
                    $changes[] = $this->getId();
                }
                $changes = Change::fetchAll(
                    array(Change::FETCH_BY_IDS => $changes),
                    $this->getConnection()
                );
            }

            foreach ($changes as $change) {
                $versions[] = array(
                    'change'  => $change->getId(),
                    'user'    => $change->isSubmitted() ? $change->getUser() : $this->get('author'),
                    'time'    => $change->getTime(),
                    'pending' => $change->isPending()
                );
            }

            // hang on to the fabricated versions so we don't query changes again
            $this->setRawValue('versions', $versions);
        }

        // ensure head rev points to the canonical shelf, but older revs do not.
        $versions = $this->normalizeVersions($versions);

        return $versions;
    }

    /**
     * Set the list of versions. Each element must specify change, user, time and pending.
     *
     * @param   array|null  $versions   the list of versions
     * @return  Review      provides fluent interface
     * @throws  \InvalidArgumentException   if any version doesn't contain change, user, time or pending.
     */
    public function setVersions(array $versions = null)
    {
        $versions = (array) $versions;
        foreach ($versions as $key => $version) {
            if (!isset($version['change'], $version['user'], $version['time'], $version['pending'])) {
                throw new \InvalidArgumentException(
                    "Cannot set versions. Each version must specify a change, user, time and pending."
                );
            }

            // normalize pending to an int for consistency with the review's pending flag.
            $version['pending'] = (int) $version['pending'];
        }

        // ensure head rev points to the canonical shelf, but older revs do not.
        $versions = $this->normalizeVersions($versions);

        return $this->setRawValue('versions', $versions);
    }

    /**
     * Add a version to the list of versions.
     *
     * @param   array   $version    the version details (change, user, time, pending)
     * @return  Review  provides fluent interface
     * @throws  \InvalidArgumentException   if the version doesn't contain change, user, time or pending.
     */
    public function addVersion(array $version)
    {
        $versions   = $this->getVersions();
        $versions[] = $version;

        return $this->setVersions($versions);
    }

    /**
     * Get highest version number.
     *
     * @return  int     max version number
     */
    public function getHeadVersion()
    {
        return count($this->getVersions());
    }

    /**
     * Convenience method to get the revision number for a given change id.
     *
     * @param   int|string|Change   $change     the change to get the rev number of.
     * @return  int                 the rev number of the change or false if no such change version
     */
    public function getVersionOfChange($change)
    {
        $change        = $change instanceof Change ? $change->getId() : $change;
        $versionNumber = false;
        foreach ($this->getVersions() as $key => $version) {
            if ($change == $version['change']
                || (isset($version['archiveChange']) && $change == $version['archiveChange'])
            ) {
                $versionNumber = $key + 1;
            }
        }

        return $versionNumber;
    }

    /**
     * Convenience method to get the change number for a given version.
     *
     * @param   int     $version    the version to get the change number of.
     * @param   bool    $archive    optional - pass true to get the archive change if available
     *                              by default returns the review id for pending head versions
     * @return  int                 the change number of the given version
     * @throws  Exception           if no such version
     */
    public function getChangeOfVersion($version, $archive = false)
    {
        $versions = $this->getVersions();
        if (isset($versions[$version - 1]['change'])) {
            $version = $versions[$version - 1];
            return $archive && isset($version['archiveChange']) ? $version['archiveChange'] : $version['change'];
        }

        throw new Exception("Cannot get change of version $version. No such version.");
    }

    /**
     * Convenience method to get the change number of the latest version.
     *
     * @param   bool    $archive    optional - pass true to get the archive change if available
     *                              by default returns the review id for pending head versions
     * @return  int|null    the change id of the latest version or null if no associated changes
     */
    public function getHeadChange($archive = false)
    {
        $head = end($this->getVersions());
        if (is_array($head) && isset($head['change'])) {
            return $archive && isset($head['archiveChange']) ? $head['archiveChange'] : $head['change'];
        }

        // if no versions, could be a new review that hasn't processed its change
        if ($this->getChanges()) {
            return max($this->getChanges());
        }

        return null;
    }

    /**
     * Convenience method to check if a given version exists.
     *
     * @param   int     $version    the version to check for (one-based)
     * @return  bool    true if the version exists, false otherwise
     */
    public function hasVersion($version)
    {
        $versions = $this->getVersions();
        return $version && isset($versions[$version - 1]);
    }

    /**
     * Get changes associated with this review record which were in a pending
     * state when they were associated with the review.
     *
     * This is a convenience method it calculates the result by diffing
     * the full change list and the committed list.
     *
     * Note, this is a historical representation; just because there are
     * pending changes associated doesn't mean the review 'isPending'.
     *
     * @return  array   list of changes associated with this record
     */
    public function getPending()
    {
        return array_values(
            array_diff($this->getChanges(), $this->getCommits())
        );
    }

    /**
     * Set this review to pending to indicate it has un-committed files.
     * Ensures the raw value is consistently stored as a 1 or 0.
     *
     * Note: this is not directly related to getPending().
     *
     * @param   bool    $pending    true if pending work is present false otherwise.
     * @return  Review  provides fluent interface
     */
    public function setPending($pending)
    {
        return $this->setRawValue('pending', $pending ? 1 : 0);
    }

    /**
     * This method lets you know if the review has any pending work in the
     * swarm managed change.
     *
     * Note, getPending returns a list of changes that were pending at the
     * time they were associated. It is quite possible getPending would return
     * items but 'isPending' would say no pending work presently exists.
     *
     * @return  bool    true if pending work is present false otherwise.
     */
    public function isPending()
    {
        return (bool) $this->getRawValue('pending');
    }

    /**
     * If the review has at least one committed change associated with it and
     * has no swarm managed pending work we consider it to be committed.
     *
     * @return  bool    true if review is committed false otherwise.
     */
    public function isCommitted()
    {
        return $this->getCommits() && !$this->isPending();
    }

    /**
     * Get the projects this review record is associated with.
     * Each entry in the resulting array will have the project id as the key and
     * an array of zero or more branches as the value. An empty branch array is
     * intended to indicate the project is affected but not a specific branch.
     *
     * @return  array   the projects set on this record.
     */
    public function getProjects()
    {
        $projects = (array) $this->getRawValue('projects');

        // remove deleted projects
        foreach ($projects as $project => $branches) {
            if (!Project::exists($project, $this->getConnection())) {
                unset($projects[$project]);
            }
        }

        return $projects;
    }

    /**
     * Set the projects (and their associated branches) that are impacted by this review.
     * @see ProjectListFilter for details on input format.
     *
     * @param   array|string    $projects   the projects to associate with this review.
     * @return  Review          provides fluent interface
     * @throws  \InvalidArgumentException   if input is not correctly formatted.
     */
    public function setProjects($projects)
    {
        $filter = new ProjectListFilter;
        return $this->setRawValue('projects', $filter->filter($projects));
    }

    /**
     * Add one or more projects (and optionally associated branches)
     *
     * @param   string|array    $projects   one or more projects
     * @return  Review          provides fluent interface
     */
    public function addProjects($projects)
    {
        $filter = new ProjectListFilter;
        return $this->setRawValue('projects', $filter->merge($this->getRawValue('projects'), $projects));
    }

    /**
     * Get groups this review record is associated with.
     *
     * @return  array   the groups set on this record.
     */
    public function getGroups()
    {
        $groups = (array) $this->getRawValue('groups');
        return array_values(array_unique(array_filter($groups, 'strlen')));
    }

    /**
     * Set the groups that are impacted by this review.
     *
     * @param   array|string    $groups     the groups to associate with this review.
     * @return  Review          provides fluent interface
     */
    public function setGroups($groups)
    {
        $groups = array_values(array_unique(array_filter($groups, 'strlen')));
        return $this->setRawValue('groups', $groups);
    }

    /**
     * Add one or more groups.
     *
     * @param   string|array    $groups   one or more groups
     * @return  Review          provides fluent interface
     */
    public function addGroups($groups)
    {
        return $this->setGroups(array_merge($this->getGroups(), (array) $groups));
    }

    /**
     * Get API token associated with this review and the latest version.
     * Note: A token is automatically created on save if one isn't already present.
     *
     * The token is intended to provide authorization when performing
     * unauthenticated updates to reviews (e.g. setting test status).
     * It also ensures that updates pertain to the latest version.
     *
     * @return  array   the token for this review with a version suffix
     */
    public function getToken()
    {
        return $this->getRawValue('token') . '.v' . $this->getHeadVersion();
    }

    /**
     * Set API token associated with this review. This method would not
     * normally be used; On save a token will automatically be created if
     * one isn't already set on the review.
     *
     * @param   string|null     $token  the token for this review
     * @return  Review          provides fluent interface
     * @throws  \InvalidArgumentException   if token is not a valid type
     */
    public function setToken($token)
    {
        if (!is_null($token) && !is_string($token)) {
            throw new \InvalidArgumentException(
                'Tokens must be a string or null'
            );
        }

        return $this->setRawValue('token', $token);
    }

    /**
     * Get the test details for this code review.
     *
     * @param   bool    $normalize  optional - flag to denote whether we normalize details
     *                              to include version and duration keys, false by default
     * @return  array               test details for this code review
     */
    public function getTestDetails($normalize = false)
    {
        $raw = (array) $this->getRawValue('testDetails');
        return $normalize
            ? $raw + array('version' => null, 'startTimes' => array(), 'endTimes' => array(), 'averageLapse' => null)
            : $raw;
    }

    /**
     * Set the test details for this code review.
     *
     * @param   array|null   $details    test details to set
     */
    public function setTestDetails($details = null)
    {
        return $this->setRawValue('testDetails', (array) $details);
    }

    /**
     * Get the deploy details for this code review.
     *
     * @return  array   test details for this code review
     */
    public function getDeployDetails()
    {
        return (array) $this->getRawValue('deployDetails');
    }

    /**
     * Set the deploy details for this code review.
     *
     * @param   array|null  $details    test details to set
     * @return  Review      to maintain a fluent interface
     */
    public function setDeployDetails($details = null)
    {
        return $this->setRawValue('deployDetails', (array) $details);
    }

    /**
     * Extends the basic save behavior to also:
     * - update hasReviewer value based on presence of 'reviewers'
     * - set create timestamp to current time if no value was provided
     * - create an api token if we don't already have one
     * - set update timestamp to current time
     *
     * @return  Review      to maintain a fluent interface
     */
    public function save()
    {
        // if upgrade level is higher than anticipated, throw hard!
        // if we were to proceed we could do some damage.
        if ((int) $this->get('upgrade') > static::UPGRADE_LEVEL) {
            throw new Exception('Cannot save. Upgrade level is too high.');
        }

        // add author to the list of participants
        $this->addParticipant($this->get('author'));

        // set hasReviewer flag
        $this->set('hasReviewer', $this->getReviewers() ? 1 : 0);

        // if no create time is already set, use now as a default
        $this->set('created', $this->get('created') ?: time());

        // create a token if we don't already have any
        $this->set('token', $this->getRawValue('token') ?: strtoupper(new Uuid));

        // always set update time to now
        $this->set('updated', time());

        return parent::save();
    }

    /**
     * Get the current upgrade level of this record.
     *
     * @return  int|null    the upgrade level when this record was created or last saved
     */
    public function getUpgrade()
    {
        // if this record did not come from a perforce key (ie. storage)
        // assume it was just made and default to the current upgrade level.
        if (!$this->isFromKey && $this->getRawValue('upgrade') === null) {
            return static::UPGRADE_LEVEL;
        }

        return $this->getRawValue('upgrade');
    }

    /**
     * Upgrade this record on save.
     *
     * @param   KeyRecord|null  $stored     an instance of the old record from storage or null if adding
     */
    protected function upgrade(KeyRecord $stored = null)
    {
        // if record is new, default to latest upgrade level
        if (!$stored) {
            $this->set('upgrade', $this->getRawValue('upgrade') ?: static::UPGRADE_LEVEL);
            return;
        }

        // if record is already at the latest upgrade level, nothing to do
        if ((int) $stored->get('upgrade') >= static::UPGRADE_LEVEL) {
            return;
        }

        // looks like we're upgrading - clear 'original' values so all fields get written
        // @todo move this down to abstract key when/if it gets smart enough to detect upgrades
        $this->original = null;

        // upgrade from 0/unset to 1:
        //  - the 'reviewer' field has been removed
        //  - the 'assigned' field has been renamed to 'hasReviewers' and is now a bool of count(reviewers)
        //  - words in the description field are now indexed in lowercase (for case-insensitive matches)
        //    with leading/trailing punctuation removed and using a slightly different split pattern.
        if ((int) $stored->get('upgrade') === 0) {
            unset($this->values['reviewer']);
            unset($this->values['assigned']);

            // need to de-index old 'assigned' field (can only have two possible values 0/1)
            $this->getConnection()->run(
                'index',
                array('-a', 1305, '-d', $this->id),
                '30 31'
            );
            $stored->set('hasReviewer', null);

            // need to de-index description the old way
            $words = array_unique(array_filter(preg_split('/[\s,]+/', $stored->get('description')), 'strlen'));
            if ($words) {
                $this->getConnection()->run(
                    'index',
                    array('-a', 1306, '-d', $this->id),
                    implode(' ', array_map('strtoupper', array_map('bin2hex', $words))) ?: 'EMPTY'
                );

                // clear old value to force re-indexing of non-empty descriptions.
                $stored->set('description', null);
            }
            $this->set('upgrade', 1);
        }

        // upgrade to 2
        //  - versions field has been introduced, get/set it to tickle upgrade code
        if ((int) $stored->get('upgrade') < 2) {
            $this->setVersions($this->getVersions());
            $this->set('upgrade', 2);
        }

        // upgrade to 3
        //  - votes merged into participants field, get/set it to tickle upgrade
        if ((int) $stored->get('upgrade') < 3) {
            $this->setParticipantsData($this->getParticipantsData());
            $this->set('upgrade', 3);
        }

        // upgrade to 4
        //  - votes expanded to array with 'value' and 'version' keys, get/set it to tickle upgrade
        if ((int) $stored->get('upgrade') < 4) {
            $this->setVotes($this->getVotes());
            $this->set('upgrade', 4);
        }
    }

    /**
     * Get topic for this review (used for comments).
     *
     * @return  string  topic for this review
     * @todo    add a getTopics which includes the associated change topics
     */
    public function getTopic()
    {
        return 'reviews/' . $this->getId();
    }

    /**
     * Try to fetch the associated author user as a user spec object.
     *
     * @return  User    the associated author user object
     * @throws  NotFoundException   if user does not exist
     */
    public function getAuthorObject()
    {
        return $this->getUserObject('author');
    }

    /**
     * Check if the associated author user is valid (exists).
     *
     * @return  bool    true if the author user exists, false otherwise.
     */
    public function isValidAuthor()
    {
        return $this->isValidUser('author');
    }

    /**
     * Get a human-friendly label for the current state.
     *
     * @return string
     */
    public function getStateLabel()
    {
        $state = $this->get('state');
        return ucfirst(preg_replace('/([A-Z])/', ' \\1', $state));
    }

    /**
     * Get a list of valid transitions for this review.
     *
     * @return  array   a list with target states as keys and transition labels as values
     */
    public function getTransitions()
    {
        $translator  = $this->getConnection()->getService('translator');
        $transitions = array(
            static::STATE_NEEDS_REVIEW         => $translator->t('Needs Review'),
            static::STATE_NEEDS_REVISION       => $translator->t('Needs Revision'),
            static::STATE_APPROVED             => $translator->t('Approve'),
            static::STATE_APPROVED . ':commit' => $translator->t('Approve and Commit'),
            static::STATE_REJECTED             => $translator->t('Reject'),
            static::STATE_ARCHIVED             => $translator->t('Archive')
        );

        // exclude current state
        unset($transitions[$this->get('state')]);

        // exclude approve and commit if we lack pending work or are already committing
        if (!$this->isPending() || $this->isCommitting()) {
            unset($transitions[static::STATE_APPROVED . ':commit']);
        }

        // if we are pending but already approved tweak the approve
        // and commit wording to just say 'Commit'
        if ($this->isPending() && $this->get('state') == static::STATE_APPROVED) {
            $transitions[static::STATE_APPROVED . ':commit'] = 'Commit';
        }

        return $transitions;
    }

    /**
     * Deletes the current review and attempts to remove indexes.
     * Extends parent to also delete the swarm managed shelf.
     *
     * @return  Review      to maintain a fluent interface
     * @throws  Exception   if no id is set
     * @throws  \Exception  re-throws any exceptions caused during delete
     * @todo    remove archive changes as well as canonical change
     */
    public function delete()
    {
        if (!$this->getId()) {
            throw new Exception(
                'Cannot delete review, no ID has been set.'
            );
        }

        // attempt to get the associated shelved change we manage
        // if no such change exists, just let parent delete this record
        $p4 = $this->getConnection();
        try {
            $shelf = Change::fetch($this->getId(), $p4);
        } catch (NotFoundException $e) {
            return parent::delete();
        }

        if ($shelf->isSubmitted()) {
            throw new Exception(
                'Cannot delete review; the shelved change we manage is unexpectedly committed.'
            );
        }

        // we'll need a valid client for this next bit.
        $p4->getService('clients')->grab();
        try {
            // try and hard reset the client to ensure a clean environment
            $p4->getService('clients')->reset(true, $this->getHeadStream());

            // if the shelf associated with this review isn't already on
            // the right client, likely won't be, swap it over and save.
            if ($shelf->getClient() != $p4->getClient() || $shelf->getUser() != $p4->getUser()) {
                $shelf->setClient($p4->getClient())->setUser($p4->getUser())->save(true);
            }

            // attempt to delete any shelved files off the swarm managed change
            // silence the expected exception that occurs when no shelved files were present
            try {
                $p4->run('shelve', array('-d', '-f', '-c', $this->getId()));
            } catch (CommandException $e) {
                if (strpos($e->getMessage(), 'No shelved files in changelist to delete.') === false) {
                    throw $e;
                }
                unset($e);
            }

            // now that the shelved files are gone try and delete the actual change
            $p4->run("change", array("-d", "-f", $this->getId()));
        } catch (\Exception $e) {
        }
        $p4->getService('clients')->release();

        if (isset($e)) {
            throw $e;
        }

        // let parent wrap up by deleting the key record and indexes
        return parent::delete();
    }

    /**
     * Attempts to figure out what stream (if any) the head version of this review
     * is against. Useful for committing the work as you'll need to be on said stream.
     *
     * @return null|string  the streams path as a string, if we can identify one, otherwise null
     */
    protected function getHeadStream()
    {
        // try to determine the stream we aught to use from the version history
        $version = end($this->getVersions());
        if (array_key_exists('stream', $version)) {
            return $version['stream'];
        }

        // if its not recorded and the head version is a pending change
        // we can try to guess the stream from the shelved file paths.
        if (isset($version['change'], $version['pending']) && $version['pending']) {
            return $this->guessStreamFromChange($version['change']);
        }

        // looks like we don't have a clue; lets assume not a stream
        return null;
    }

    /**
     * Checks the first file in a change to see if it points to a streams depot.
     * Note, this check may not work reliably on streams with writable imports.
     *
     * @param   int|string|Change   $change     the change to look at for our guess
     * @return  null|string         the streams path as a string, if we can identify one, otherwise null
     */
    protected function guessStreamFromChange($change)
    {
        $p4     = $this->getConnection();
        $change = $change instanceof Change ? $change : Change::fetch($change, $p4);
        $id     = $change->getId();
        $flags  = $change->isPending() ? array('-Rs') : array();
        $flags  = array_merge($flags, array('-e', $id, '-m1', '-T', 'depotFile', '//...@=' . $id));
        $result = $p4->run('fstat', $flags);
        $file   = $result->getData(0, 'depotFile');

        // if the change is empty, we can't do the check
        if ($file === false) {
            return null;
        }

        // grab the depot off the first file and check if it points to a stream depot
        // if so, return the //<depot> followed by path components equal to stream depth (this
        // field is present only on new servers, on older ones we take just the first one)
        $pathComponents = array_filter(explode('/', $file));
        $depot          = Depot::fetch(current($pathComponents), $p4);
        if ($depot->get('Type') == 'stream') {
            $depth = $depot->hasField('StreamDepth') ? $depot->getStreamDepth() : 1;
            return count($pathComponents) > $depth
                ? '//' . implode('/', array_slice($pathComponents, 0, $depth + 1))
                : null;
        }

        return null;
    }

    /**
     * Synchronizes the current review's description as well as the descriptions of any associated changes.
     *
     * @param   string           $reviewDescription  the description to use for the review (review keywords stripped)
     * @param   string           $changeDescription  the description to use for the change (review keywords intact)
     * @param   Connection|null  $connection         the perforce connection to use - should be p4 admin, since the
     *                                               current user may not own all the associated changes
     * @return  bool             true if the review description was modified, false otherwise
     */
    public function syncDescription($reviewDescription, $changeDescription, $connection = null)
    {
        $wasModified = false;

        // update the review with the new review description, if needed
        if ($this->getDescription() != $reviewDescription) {
            $this->setDescription($reviewDescription)->save();

            // since we changed the description, we've modified this review
            $wasModified = true;
        }

        // update descriptions for all changes associated with the review
        try {
            $connection = $connection ?: $this->getConnection();
            $connection->getService('clients')->grab();
            foreach ($this->getChanges() as $changeId) {
                $change = Change::fetch($changeId, $connection);

                // note: we only want to save the change if the description was changed, since this will trigger
                // an infinite number of changesave events otherwise
                if ($change->getDescription() != $changeDescription) {
                    $change->setDescription($changeDescription)
                           ->save(true);
                }
            }
        } catch (\Exception $e) {
            Logger::log(Logger::ERR, $e);
        }

        $connection->getService('clients')->release();
        return $wasModified;
    }

    /**
     * Try to fetch the associated user (for given field) as a user spec object.
     *
     * @param   string  $userField  name of the field to get user object for
     * @return  User    the associated user object
     * @throws  NotFoundException   if user does not exist
     */
    protected function getUserObject($userField)
    {
        if (!isset($this->userObjects[$userField])) {
            $this->userObjects[$userField] = User::fetch(
                $this->get($userField),
                $this->getConnection()
            );
        }

        return $this->userObjects[$userField];
    }

    /**
     * Check if the associated user (for given field) is valid (exists).
     *
     * @param   string  $userField  name of the field to check user for
     * @return  bool    true if the author user exists, false otherwise.
     */
    protected function isValidUser($userField)
    {
        try {
            $this->getUserObject($userField);
        } catch (NotFoundException $e) {
            return false;
        }

        return true;
    }

    /**
     * Override parent to prepare 'project' field values for indexing.
     *
     * @param   int                 $code   the index code/number of the field
     * @param   string              $name   the field/name of the index
     * @param   string|array|null   $value  one or more values to index
     * @param   string|array|null   $remove one or more old values that need to be de-indexed
     * @return  Review              provides fluent interface
     */
    protected function index($code, $name, $value, $remove)
    {
        // convert 'projects' field values into the form suitable for indexing
        // we index projects by project-id, but also by project-id:branch-id.
        if ($name === 'projects') {
            $value  = array_merge(array_keys((array) $value),  static::flattenForIndex((array) $value));
            $remove = array_merge(array_keys((array) $remove), static::flattenForIndex((array) $remove));
        }

        return parent::index($code, $name, $value, $remove);
    }

    /**
     * Called when an auto-generated ID is required for an entry.
     *
     * Extends parent to create a new changelist and use its change id
     * as the identifier for the review record.
     *
     * @return  string      a new auto-generated id. the id will be 'encoded'.
     * @throws  \Exception  re-throws any errors which occur during change save operation
     */
    protected function makeId()
    {
        $p4    = $this->getConnection();
        $shelf = new Change($p4);
        $shelf->setDescription($this->get('description'));

        // we grab the client tightly around save to avoid
        // locking it for any longer than we have to.
        $p4->getService('clients')->grab();
        try {
            $shelf->save();
        } catch (\Exception $e) {
        }
        $p4->getService('clients')->release();

        if (isset($e)) {
            throw $e;
        }

        return $this->encodeId($shelf->getId());
    }

    /**
     * Extends parent to flip the ids ordering and hex encode.
     *
     * @param   string|int  $id     the user facing id
     * @return  string      the stored id used by p4 key
     */
    protected static function encodeId($id)
    {
        // nothing to do if the id is null
        if (!strlen($id)) {
            return null;
        }

        // subtract our id from max 32 bit int value to ensure proper sorting
        // we use a 32 bit value even on 64 bit systems to allow interoperability.
        $id = 0xFFFFFFFF - $id;

        // start with our prefix and follow up with hex encoded id
        // (the higher base makes it slightly shorter)
        $id = str_pad(dechex($id), 8, '0', STR_PAD_LEFT);
        return static::KEY_PREFIX . $id;
    }

    /**
     * Extends parent to undo our flip logic and hex decode.
     *
     * @param   string  $id     the stored id used by p4 key
     * @return  string|int      the user facing id
     */
    protected static function decodeId($id)
    {
        // nothing to do if the id is null
        if ($id === null) {
            return null;
        }

        // strip off our key prefix
        $id = substr($id, strlen(static::KEY_PREFIX));

        // hex decode it and subtract from 32 bit int to undo our sorting trick
        return (int) (0xFFFFFFFF - hexdec($id));
    }

    /**
     * Produces a 'p4 search' expression for the given field/value pairs.
     *
     * Extends parent to allow including pending status in the state filter.
     * The syntax is <state>:(isPending|notPending) e.g.:
     * approved:notPending
     *
     * @param   array   $conditions     field/value pairs to search for
     * @return  string  a query expression suitable for use with p4 search
     */
    protected static function makeSearchExpression($conditions)
    {
        // normalize conditions and pull out the 'states' for us to deal with
        $conditions += array(static::FETCH_BY_STATE => '');
        $states      = $conditions[static::FETCH_BY_STATE];

        // start by letting parent handle all other fields
        unset($conditions[static::FETCH_BY_STATE]);
        $expression = parent::makeSearchExpression($conditions);

        // go over all state(s) and utilize parent to build expression for the state and
        // optional isPending/notPending field. We do them one at a time to allow us to
        // bracket the output when the expression has both state and pending.
        $expressions = array();
        foreach ((array) $states as $state) {
            $conditions = array();
            $parts      = explode(':', $state);

            // if state appears to contain an isPending or notPending component split it
            // into separate state and pending conditions, otherwise simply keep it as is.
            if (count($parts) == 2 && ($parts[1] == 'isPending' || $parts[1] == 'notPending')) {
                $conditions[static::FETCH_BY_STATE] = $parts[0];
                $conditions['pending']              = $parts[1] == 'isPending' ? 1 : 0;
            } else {
                $conditions[static::FETCH_BY_STATE] = $state;
            }

            // use parent to make the state's expression then add it to the pile and
            // bracket it if we asked for both the state and pending filter
            $state         = parent::makeSearchExpression($conditions);
            $expressions[] = count($conditions) > 1 ? '(' . $state . ')' : $state;
        }

        // now that we've collected up all the state expressions, implode and bracket
        // the whole thing if more than one state's involved
        $states      = implode(' | ', $expressions);
        $expression .= ' ' . (count($expressions) > 1 ? '(' . $states . ')' : $states);

        return trim($expression);
    }

    /**
     * Turn the passed key into a record.
     * Extends parent to detect review type and create the appropriate review class.
     *
     * @param   Key             $key        the key to record'ize
     * @param   string|callable $className  optional - class name to use, static by default
     * @return  Review  the record based on the passed key's data
     */
    protected static function keyToModel($key, $className = null)
    {
        return parent::keyToModel(
            $key,
            $className ?: function ($data) {
                // if the data includes a type of git; make a git model
                // otherwise create a standard review.
                return isset($data['type']) && $data['type'] === 'git'
                    ? '\Reviews\Model\GitReview'
                    : '\Reviews\Model\Review';
            }
        );
    }

    /**
     * Copy files and description to a new shelved change and add a version entry.
     *
     * We use shelved changes for versioning so that users can un-shelve old versions
     * and so that the rest of our diff/etc. code works with them seamlessly.
     *
     * This method is only intended to be called from updateFromChange().
     *
     * @param   Change  $shelf              the shelved change to archive files from
     * @param   array   $versionDetails     extra details to include in version entry, e.g. difference => true
     * @return  Review  provides fluent interface
     */
    protected function archiveShelf(Change $shelf, $versionDetails)
    {
        // make a new change matching the shelf's type and description.
        $p4     = $this->getConnection();
        $change = new Change($p4);
        $change->setType($shelf->getType())
               ->setDescription($shelf->getDescription())
               ->save();

        // to avoid any ambiguity when the shelve-commit trigger fires we add the
        // new archive change/version to the review record before we shelve
        $version = $versionDetails + array(
            'change'     => $change->getId(),
            'user'       => $shelf->getUser(),
            'time'       => time(),
            'pending'    => true
        );
        $this->addVersion($version)
             ->addChange($change->getId())
             ->save();

        // now we can move the files into our archive change and shelve them.
        $p4->run('reopen', array('-c', $change->getId(), '//...'));
        $p4->run('shelve', array('-c', $change->getId()));

        return $this;
    }

    /**
     * Rescue files from a pre-versioning review (upgrade scenario).
     *
     * Copy files and description to a new shelved change and update the latest
     * version in our versions metadata to point to the new change.
     *
     * This method is only intended to be called from updateFromChange().
     *
     * @param   Change  $shelf  the canonical shelved change to archive files from
     * @return  Review  provides fluent interface
     * @todo    centralize this more robust unshelve logic and use it elsewhere
     */
    protected function retroactiveArchive(Change $shelf)
    {
        // determine if we have any files to archive
        // we expect some files may fail to unshelve (this happens on <13.1
        // servers with added files that are now submitted) we capture these
        // files and sync/edit/print them manually to save the file contents
        $p4      = $this->getConnection();
        $result  = $p4->run('unshelve', array('-s', $shelf->getId()));
        $opened  = 0;
        $failed  = array();
        $pattern = "/^Can't unshelve (.*) to open for [a-z\/]+: file already exists.$/";
        foreach ($result->getData() as $data) {
            if (is_array($data)) {
                $opened++;
            } elseif (preg_match($pattern, $data, $matches)) {
                $failed[] = $matches[1];
            }
        }

        // if there were no files to unshelve, exit early.
        if (!$opened && !$failed) {
            return $this;
        }

        // emulate unshelve for out-dated adds on <13.1 servers
        if ($failed) {
            $p4->run('sync', array_merge(array('-k'), $failed));
            $p4->run('edit', array_merge(array('-k'), $failed));
            foreach ($failed as $file) {
                $local = $p4->run('where', $file)->getData(0, 'path');
                $p4->run('print', array('-o', $local, $file . '@=' . $shelf->getId()));
            }
        }

        // now that we know we have files to rescue - make a new change for them.
        $change = new Change($p4);
        $change->setType($shelf->getType())
               ->setDescription($shelf->getDescription())
               ->save();

        // to avoid any ambiguity when the shelve-commit trigger fires we add the
        // new archive change/version to the review record before we shelve
        $versions                                        = $this->getVersions();
        $versions[count($versions) - 1]['archiveChange'] = $change->getId();
        $this->setVersions($versions)
             ->addChange($change->getId())
             ->save();

        // now we can move the files into our archive change and shelve them.
        $p4->run('reopen', array('-c', $change->getId(), '//...'));
        $p4->run('shelve', array('-c', $change->getId()));

        // shelving leaves files open in the workspace, we need to clean those up
        // otherwise they will interfere with updating the canonical shelf later
        $p4->getService('clients')->clearFiles();

        return $this;
    }

    /**
     * Pending head revisions are stored twice, once in the canonical shelf and again in an archive shelf.
     * This method ensures the head version points to the canonical shelf, but older versions do not.
     *
     * @param   array   $versions   the list of versions to normalize
     * @return  array   the normalized versions with head/non-head change issues sorted
     */
    protected function normalizeVersions(array $versions)
    {
        $last = end(array_keys($versions));
        foreach ($versions as $key => $version) {
            // if we see a pending head rev that does not point to the canonical shelf,
            // update it to point there and capture the archive change for later use.
            if ($version['pending'] && $version['change'] != $this->getId() && $key == $last) {
                $versions[$key]['archiveChange'] = $version['change'];
                $versions[$key]['change']        = $this->getId();
            }

            // if we find a non-head rev that points to the canonical shelf, update it
            // to reference the archive change or drop it if it has no archive change
            // if it has no archive change, it is most likely cruft from the upgrade code
            if ($version['change'] == $this->getId() && $key != end(array_keys($versions))) {
                if (isset($version['archiveChange'])) {
                    $versions[$key]['change'] = $version['archiveChange'];
                    unset($versions[$key]['archiveChange']);
                } else {
                    unset($versions[$key]);
                }
            }
        }

        return array_values($versions);
    }

    /**
     * Determine if files in the given changes (pending or submitted) are different in any meaningful way.
     * We compare following properties:
     *  - file names
     *  - file contents (digests)
     *  - file types
     *  - actions
     *  - working (head) revs
     *  - resolved/unresolved states
     * and return an integer based on the results:
     *  0 if changes don't differ in any of compared properties
     *  1 if any file names, contents or types differ
     *  2 if changes differ in any other compared properties.
     *
     * @param   Change|int  $a  pending or submitted change to compare
     * @param   Change|int  $b  pending or submitted change to compare
     * @return  int         0 if changes don't differ
     *                      1 if changes differ in file names, types or digests
     *                      2 if changes differ in any other compared fields
     */
    protected function changesDiffer($a, $b)
    {
        $p4  = $this->getConnection();
        $a   = $a instanceof Change ? $a : Change::fetch($a, $p4);
        $b   = $b instanceof Change ? $b : Change::fetch($b, $p4);
        $aId = $a->getId();
        $bId = $b->getId();

        $flags = array(
            '-Ol',  // include digests
            '-T',   // only the fields we want:
            'depotFile,headAction,headType,headRev,resolved,unresolved,digest'
        );

        // add '-Rs' flag for pending changes
        $flagsA = array_merge($a->isPending() ? array('-Rs') : array(), $flags);
        $flagsB = array_merge($b->isPending() ? array('-Rs') : array(), $flags);

        $a = $p4->run(
            'fstat',
            array_merge(array('-e', $a->getId()), $flagsA, array('//...@=' . $a->getId()))
        );
        $b = $p4->run(
            'fstat',
            array_merge(array('-e', $b->getId()), $flagsB, array('//...@=' . $b->getId()))
        );

        // remove trailing change descriptions - we don't care if they differ
        $a = $a->getData(-1, 'desc') !== false ? array_slice($a->getData(), 0, -1) : $a->getData();
        $b = $b->getData(-1, 'desc') !== false ? array_slice($b->getData(), 0, -1) : $b->getData();

        if ($a == $b) {
            return 0;
        }

        // the fstat reported digests for ktext files are not what we want.
        // they are based on the text with keywords expanded which is apt to harmlessly flux.
        // if it looks worthwhile, we want to recalculate md5s without expansion.
        if ($this->shouldFixDigests($a, $b)) {
            $a = $this->fixKeywordExpandedDigests($a, $aId);
            $b = $this->fixKeywordExpandedDigests($b, $bId);
        }

        // our ktext related md5 updates may have cleared the difference; if so we're done!
        if ($a == $b) {
            return 0;
        }

        // screen down to only the 'major' difference fields
        $whitelist = array('depotFile' => null, 'headType' => null, 'digest' => null);
        foreach ($a as $block => $data) {
            $a[$block] = array_intersect_key($data, $whitelist);
        }
        foreach ($b as $block => $data) {
            $b[$block] = array_intersect_key($data, $whitelist);
        }

        // if the data are same now, it means that differences must have been within
        // action, revs or resolved/unresolved; otherwise changes must differ in other fields
        return $a == $b ? 2 : 1;
    }

    /**
     * This is a helper method for changesDiffer. We determine if touching up keyword expanded
     * digests is worthwhile.
     *
     * @param   array   $a  fstat output with list of files to potentially update for old change
     * @param   array   $b  fstat output with list of files to potentially update for new change
     * @return  bool    true if calling fixKeywordExpandedDigests is likely worthwhile, false otherwise
     */
    protected function shouldFixDigests($a, $b)
    {
        // differing counts means changesDiffer will always report 1; no need to fix digests
        if (count($a) != count($b)) {
            return false;
        }

        // index all 'b' blocks by depotFile so we can correlate them later
        $bByFile = array();
        foreach ($b as $key => $block) {
            if (isset($block['depotFile'])) {
                $bByFile[$block['depotFile']] = $block;
            }
        }

        $hasKtext  = false;
        $normalize = array('depotFile' => null, 'digest' => null, 'headType' => null);
        foreach ($a as $blockA) {
            // if the 'b' set doesn't include this file, no need to fix digests
            $blockA += $normalize;
            $file    = $blockA['depotFile'];
            if (!isset($bByFile[$file])) {
                return false;
            }
            $blockB = $bByFile[$file] + $normalize;

            // if type has changed on any file, no need to fix digests
            if ($blockA['headType'] != $blockB['headType']) {
                return false;
            }

            // if a single non-ktext file has a changed digest, no need to fixup
            $isKtext = preg_match('/kx?text|.+\+.*k/i', $blockA['headType']);
            if (!$isKtext && $blockA['digest'] != $blockB['digest']) {
                return false;
            }

            // track if we've hit any ktext files
            $hasKtext = $hasKtext || $isKtext;
        }

        // if we made it this far, fixing ktext digests is likely worthwhile if we've seen any
        return $hasKtext;
    }

    /**
     * This is a helper method for changesDiffer. We get passed in the fstat output for one
     * of the changes being examined and locate any ktext files located in it. We then print
     * all of the ktext files and recalculate the md5 values with the keywords not expanded.
     *
     * This will allow the changes differ method to tell if the ktext files fundamentally
     * differ (as opposed to simply differ in the expanded keywords).
     *
     * @param   array   $blocks     fstat output with list of files to potentially update
     * @param   int     $changeId   change id to use for revspec when printing files
     * @return  array   the provided blocks array with ktext digests updated
     */
    protected function fixKeywordExpandedDigests($blocks, $changeId)
    {
        // we cannot do squat on pre 2012.2 servers as they don't support printing with
        // keywords unexpanded. if we're on an old server, simply return.
        $p4 = $this->getConnection();
        if (!$p4->isServerMinVersion('2012.2')) {
            return $blocks;
        }

        // first collect the key and depotPath for all ktext entries and a list of filespecs with revspec
        $ktexts    = array();
        $filespecs = array();
        foreach ($blocks as $block => $data) {
            // note ktext filetypes include things like: ktext, text+ko, text+mko, kxtext, etc.
            if (isset($data['headType'], $data['depotFile']) && preg_match('/kx?text|.+\+.*k/i', $data['headType'])) {
                $file          = $data['depotFile'];
                $ktexts[$file] = $block;
                $filespecs[]   = $file . '@=' . $changeId;
            }
        }

        // if we didn't detect any ktext files we need to update, we're done!
        if (!$filespecs) {
            return $blocks;
        }

        // now setup an output handler to process the print output for all ktext files (with keywords unexpanded)
        // and do a streaming calculation of the md5 for all ktext files
        $file    = null;
        $hash    = null;
        $handler = new Limit;
        $handler->setOutputCallback(
            function ($data, $type) use (&$blocks, &$file, &$hash, $ktexts) {
                // if its an array with depotFile; we're swapping files
                if (is_array($data) && isset($data['depotFile'])) {
                    // if we were already on a file, finalize its hash update
                    if ($file !== null) {
                        $blocks[$ktexts[$file]]['digest'] = hash_final($hash);
                    }

                    // record the new file we're on and (re)init the streaming hash
                    $file = $data['depotFile'];
                    $hash = hash_init('md5');
                    return Limit::HANDLER_HANDLED;
                }

                // if we have an unexpected type, skip it
                if ($type !== 'text' && $type !== 'binary') {
                    return Limit::HANDLER_HANDLED;
                }

                // update the hash with our new block of content
                hash_update($hash, $data);

                return Limit::HANDLER_HANDLED;
            }
        );

        // print via our handler, note we pass -k to avoid expanding keywords
        // thanks to our output handler this will update the digest values in the $blocks array
        $p4->runHandler($handler, 'print', array_merge(array('-k'), $filespecs));

        // we're likely to have a final file to wrap up the hash update on, do that
        if ($file) {
            $blocks[$ktexts[$file]]['digest'] = hash_final($hash);
        }

        return $blocks;
    }

    /**
     * General normalization of participants data.
     *
     * @param   array|null  $participants   the participants array to normalize
     * @param   bool        $forStorage     optional - flag to denote whether we normalize for storage
     *                                      passed to normalizeVote(), false by default
     * @return  array       normalized participants data
     */
    protected function normalizeParticipants($participants, $forStorage = false)
    {
        // - ensure value is an array
        // - ensure each entry is an array
        // - ensure the author is always present
        // - ensure we're sorted by user id
        // - ensure properties are sorted by key
        // - drop empty properties, at present we only store votes/required and
        //   its a waste of space (and less normalized) to store empty versions
        $participants  = array_filter((array) $participants, 'is_array');
        $participants += array($this->get('author') => array());
        uksort($participants, 'strnatcasecmp');

        foreach ($participants as $id => $participant) {
            $participant        += array('vote' => array());
            $participant['vote'] = $this->normalizeVote($id, $participant['vote'], $forStorage);
            $participants[$id]   = array_filter($participant);

            uksort($participants[$id], 'strnatcasecmp');
        }

        return $participants;
    }

    /**
     * If we were passed vote with valid 'value', we will ensure 'version' and 'isStale' is also present
     * ('isStale' is always recalculated).
     * If a non-array is passed, we will move the passed value under the 'value' key.
     * If no version is present, we will set the version to head.
     *
     * @param   string          $user           user of the vote
     * @param   array|string    $vote           vote to normalize
     * @param   bool            $forStorage     flag to denote whether we normalize for storage or not
     *                                          false by default; if true, then 'isStale' property will
     *                                          not be included
     * @return  array|false     normalized vote as array with 'value', 'version' and optionally
     *                          'isStale' keys or false if 'value' was invalid or user is the author
     */
    protected function normalizeVote($user, $vote, $forStorage)
    {
        // for non-array, shift the input under the 'value' key
        $vote = is_array($vote) ? $vote : array('value' => $vote);

        // if the user is the author or the vote is missing/invalid bail
        if ($user === $this->get('author') || !isset($vote['value']) || !in_array($vote['value'], array(1, -1))) {
            return false;
        }

        if (!isset($vote['version']) || !ctype_digit((string) $vote['version'])) {
            $vote['version'] = $this->getHeadVersion();
        }
        $vote['version'] = (int) $vote['version'];

        if ($forStorage) {
            unset($vote['isStale']);
        } else {
            $vote['isStale'] = $this->isStaleVote($vote);
        }

        return $vote;
    }

    /**
     * If the vote is out-dated and a newer version of the review has file changes, the vote is stale.
     * Otherwise you have voted on the same files as the latest version, so the vote is not stale.
     *
     * @param   array   $vote   vote to check
     * @return  boolean         true if vote is stale, false otherwise
     */
    protected function isStaleVote(array $vote)
    {
        // loop over the versions, oldest to newest
        $votedOn = isset($vote['version']) ? (int) $vote['version'] : 0;
        foreach ($this->getVersions() as $key => $version) {
            // skip old versions and the version voted on
            // note key starts at zero, votedOn starts at 1
            if ($key < $votedOn) {
                continue;
            }

            // if 'difference' isn't present or its invalid, assume its different and return stale
            if (!isset($version['difference'])
                || !ctype_digit((string) $version['difference'])
                || !in_array($version['difference'], array(0, 1, 2))
            ) {
                return true;
            }

            // return stale if significant change occurred, otherwise keep scanning
            // 0 - no changes, 1 - modified name, type or digest, 2 - modified only insignificant fields
            if ($version['difference'] == 1) {
                return true;
            }
        }

        // the vote is not stale
        return false;
    }

    /**
     * Check for files that cannot be opened because they are already exclusively open.
     * We need an explicit check for this because it is not reported as an error or a warning.
     *
     * @param   CommandResult  $result  the command output to examine
     * @throws  Exception      if any of the files are already open exclusively elsewhere
     */
    protected function exclusiveOpenCheck(CommandResult $result)
    {
        foreach ($result->getData() as $block) {
            if (is_string($block) && strpos($block, 'exclusive file already opened')) {
                throw new Exception(
                    'Cannot unshelve review (' . $this->getId() . '). ' .
                    'One or more files are exclusively open. ' .
                    'Ensure you have Perforce Server version 2014.2/1073410+ ' .
                    'with the filetype.bypasslock configurable enabled.'
                );
            }
        }
    }

    /**
     * Check if the server we are talking to supports bypassing +l
     *
     * @return  bool  true if the server is newer than 2014.2/1073410
     */
    protected function canBypassLocks()
    {
        $p4       = $this->getConnection();
        $identity = $p4->getServerIdentity();

        return $p4->isServerMinVersion('2014.2') && $identity['build'] >= 1073410;
    }
}