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

namespace P4\Spec;

use P4\Validate;
use P4\Spec\Job;
use P4\File\File;
use P4\File\Query as FileQuery;
use P4\Exception as P4Exception;
use P4\Connection\ConnectionInterface;
use P4\Connection\Exception\CommandException;
use P4\Connection\Exception\ConflictException;
use P4\Model\Resolvable\ResolvableInterface;
use P4\Model\Fielded\Iterator as FieldedIterator;
use P4\Spec\Exception\Exception;
use P4\Spec\Exception\UnopenedException;
use P4\Spec\Exception\NotFoundException;

class Change extends PluralAbstract implements ResolvableInterface
{
    const SPEC_TYPE             = 'change';
    const ID_FIELD              = 'Change';

    const DEFAULT_CHANGE        = 'default';
    const PENDING_CHANGE        = 'pending';
    const SUBMITTED_CHANGE      = 'submitted';

    const PUBLIC_CHANGE         = 'public';
    const RESTRICTED_CHANGE     = 'restricted';

    const FETCH_BY_FILESPEC     = 'filespec';
    const FETCH_BY_IDS          = 'ids';
    const FETCH_BY_STATUS       = 'status';
    const FETCH_INTEGRATED      = 'integrated';
    const FETCH_BY_CLIENT       = 'client';
    const FETCH_BY_USER         = 'user';

    const RESOLVE_FILE          = 'file';

    const MAX_SUBMIT_ATTEMPTS   = 3;

    protected $cache            = array();
    protected $fixStatus        = null;
    protected $fields           = array(
        'Date'          => array(
            'accessor'  => 'getDate'
        ),
        'User'          => array(
            'accessor'  => 'getUser',
            'mutator'   => 'setUser'
        ),
        'Client'        => array(
            'accessor'  => 'getClient',
            'mutator'   => 'setClient'
        ),
        'Status'        => array(
            'accessor'  => 'getStatus'
        ),
        'Description'   => array(
            'accessor'  => 'getDescription',
            'mutator'   => 'setDescription'
        ),
        'JobStatus'     => array(
            'accessor'  => 'getJobStatus',
            'mutator'   => 'setJobStatus'
        ),
        'Jobs'          => array(
            'accessor'  => 'getJobs',
            'mutator'   => 'setJobs'
        ),
        'Type'          => array(
            'accessor'  => 'getType',
            'mutator'   => 'setType'
        ),
        'Files'         => array(
            'accessor'  => 'getFiles',
            'mutator'   => 'setFiles'
        )
    );

    /**
     * Get the number of this change.
     * Extends parent to return an integer value for numbered changes.
     *
     * @return  null|string|int     the number of the change, the literal string 'default'
     *                              or null if no id has been set.
     */
    public function getId()
    {
        $id = parent::getId();
        if ($id !== null && $id !== static::DEFAULT_CHANGE) {
            $id = intval($id);
        }
        return $id;
    }

    /**
     * Set the id of this spec entry. Id must be in a valid format or null.
     * Extended from parent to clear cache.
     *
     * @param   null|string     $id     the id of this entry - pass null to clear.
     * @return  PluralAbstract          provides a fluent interface
     * @throws  \InvalidArgumentException   if id does not pass validation.
     */
    public function setId($id)
    {
        if ($this->getId() !== $id) {
            $this->cache = array();
        }

        return parent::setId($id);
    }

    /**
     * Determine if the given change id exists.
     *
     * @param   string                   $id          the id to check for.
     * @param   ConnectionInterface      $connection  optional - a specific connection to use.
     * @return  bool  true if the given id matches an existing change.
     */
    public static function exists($id, ConnectionInterface $connection = null)
    {
        // check id for valid format
        if (!static::isValidId($id)) {
            return false;
        }

        // if no connection given, use default.
        $connection = $connection ?: static::getDefaultConnection();

        // default change always exists.
        if ($id === static::DEFAULT_CHANGE) {
            return true;
        }

        // attempt to fetch change - check message on failure.
        try {
            $connection->run('change', array('-o', $id));
            return true;
        } catch (P4Exception $e) {
            if (preg_match('/Change [0-9]+ unknown/', $e->getMessage())) {
                return false;
            }
            throw $e;
        }
    }

    /**
     * Get the requested change entry from Perforce.
     *
     * @param   string                      $id         the id of the change to fetch.
     * @param   ConnectionInterface         $connection optional - a specific connection to use.
     * @return  Change                      instance of the requested change.
     * @throws  \InvalidArgumentException   if no id is given.
     * @throws  Spec\NotFoundException      if no such change exists.
     */
    public static function fetch($id, ConnectionInterface $connection = null)
    {
        // ensure a valid id is provided.
        if (!static::isValidId($id)) {
            throw new \InvalidArgumentException("Must supply a valid id to fetch.");
        }

        // if no connection given, use default.
        $connection = $connection ?: static::getDefaultConnection();

        // attempt to fetch change - check message on failure.
        try {
            // for numbered changes we run '-o <id>'. for servers running
            // 2012.1+ we include -O to allow locating renamed changes.
            // for the default change simply run '-o'.
            $flags = array();
            if ($id != static::DEFAULT_CHANGE) {
                $flags[] = $connection->isServerMinVersion('2012.1') ? '-Oo' : '-o';
                $flags[] = $id;
            } else {
                $flags[] = '-o';
            }

            $result  = $connection->run('change', $flags)->expandSequences();
            $spec    = new static($connection);

            // if we fetched the default change ensure we have the id default.
            // the id would otherwise be 'new'.
            $data = $result->getData(-1);
            if ($id == static::DEFAULT_CHANGE) {
                $data['Change'] = $id;
            }

            // if we don't get any jobs back, that means the change doesn't have any
            // merge in an empty jobs array to avoid a needless populate call later
            $spec->setRawValues($data + array('Jobs' => array()))
                 ->deferPopulate();
        } catch (P4Exception $e) {
            if (preg_match('/Change [0-9]+ unknown|Invalid changelist number/', $e->getMessage())) {
                throw new NotFoundException(
                    "Cannot fetch " . static::SPEC_TYPE . " $id. Record does not exist."
                );
            }
            throw $e;
        }

        return $spec;
    }

    /**
     * Get all changes from Perforce. Adds filtering options.
     *
     * @param   array   $options    optional - array of options to augment fetch behavior.
     *                              supported options are:
     *
     *                                   FETCH_MAXIMUM - set to integer value to limit to the
     *                                                   first 'max' number of entries.
     *                               FETCH_BY_FILESPEC - set to a filespec to limit changes to those
     *                                                   affecting the file(s) matching the filespec.
     *                                    FETCH_BY_IDS - set to an array of change IDs to limit results.
     *                                 FETCH_BY_STATUS - set to a valid change status to limit result
     *                                                   to changes with that status (e.g. 'pending').
     *                                FETCH_INTEGRATED - set to true to include changes integrated
     *                                                   into the specified files.
     *                                 FETCH_BY_CLIENT - set to a client to limit changes to those
     *                                                   on the named client.
     *                                   FETCH_BY_USER - set to a user to limit changes to those
     *                                                   owned by the named user.
     *
     * @param   ConnectionInterface     $connection  optional - a specific connection to use.
     * @return  FieldedIterator         all records of this type.
     */
    public static function fetchAll($options = array(), ConnectionInterface $connection = null)
    {
        // if fetch by ids was passed by is an empty array just return an empty result
        // otherwise the caller would actually get all changes back erroneously.
        $options += array(static::FETCH_BY_IDS => null);
        $ids      = $options[static::FETCH_BY_IDS];
        if (is_array($ids) && !count($ids)) {
            return new FieldedIterator;
        }

        // simply return parent - method exists to document options.
        return parent::fetchAll($options, $connection);
    }

    /**
     * Get all of the required fields.
     * Extends parent to remove 'Description' if present as, despite
     * being listed as required in the spec, it isn't required.
     *
     * @return  array   a list of required fields for this spec.
     */
    public function getRequiredFields()
    {
        $fields = parent::getRequiredFields();
        unset($fields[array_search('Description', $fields)]);

        return $fields;
    }

    /**
     * Extend parent to set id to 'new' if unset and to reopen files that
     * are open in other pending changelists where necessary.
     *
     * @param   bool        $force      optional - default false - true to save submitted change.
     * @return  Change      provides a fluent interface
     * @throws  UnopenedException       if change contains unopened files.
     * @throws  CommandException        if save command fails for some reason.
     */
    public function save($force = false)
    {
        $values = $this->getRawValues();
        if (!isset($values[static::ID_FIELD]) || $this->isDefault()) {
            $values[static::ID_FIELD] = "new";
        }

        // ensure all required fields have values.
        $this->validateRequiredFields($values);

        // can't update a submitted change without the force option.
        if (!$force && $this->isSubmitted()) {
            throw new Exception(
                "Cannot update a submitted change without the force option."
            );
        }

        // don't attempt to set files on submitted changes
        if ($this->isSubmitted()) {
            unset($values['Files']);
        }

        // extend the list of jobs to include the fix status if fixStatus is set
        if ($this->fixStatus && isset($values['Jobs'])) {
            foreach ($values['Jobs'] as &$value) {
                $value .= ' ' . $this->fixStatus;
            }
        }

        // perform save.
        $connection = $this->getConnection();
        try {

            $flags = array("-i");
            if ($this->fixStatus) {
                $flags[] = "-s";
            }
            if ($force) {
                $flags[] = $connection->isAdminUser(true) ? "-f" : "-u";
            }
            $result = $connection->run(static::SPEC_TYPE, $flags, $values);

            // extract change number from last command result.
            $matches = false;
            foreach ($result->getData() as $data) {
                if (preg_match('/^Change ([^ ]+) (created|updated)/', $data, $matches)) {
                    break;
                }
            }

            if (!$matches) {
                throw new Exception('Cannot determine number of saved change.');
            }

            $id = $matches[1];

        } catch (CommandException $e) {

            // if the exception was caused by non-existent jobs, the change should
            // have been created.
            if (preg_match(
                "/Change ([^ ]+) (created|updated).*Job '([^']+)' doesn't exist\./s",
                $e->getMessage(),
                $matches
            )) {
                $this->setId($matches[1]);
                throw $e;
            }

            // if exception not caused by un-opened files, re-throw.
            if (strpos($e->getMessage(), "Can't include file(s) not already opened.") === false) {
                throw $e;
            }

            // if any files are truly un-opened throw a special un-opened files exception.
            // (save will complain of un-opened files if files are not in default change)
            $flags  = array("-Ro", "-T", "depotFile");
            $flags  = array_merge($flags, $values['Files']);
            $result = $connection->run("fstat", $flags);
            if ($result->hasWarnings()) {
                throw new UnopenedException(
                    "Cannot save change. One or more files are not open."
                );
            }

            // all files are actually open, save w.out files first, then reopen.
            $change = clone $this;
            $id     = $change->setFiles(null)->save()->getId();
            $flags  = $values['Files'];
            array_unshift($flags, "-c", $id);
            $connection->run("reopen", $flags);

        }

        // Store the retrieved id.
        $this->setId($id);

        // should re-populate (server may change values).
        $this->deferPopulate(true);

        return $this;
    }

    /**
     * Save and submit this changelist.
     *
     * @param   string              $description    optional - a description of this change.
     * @param   null|string|array   $options        optional resolve flags, to be used if conflict
     *                                              occurs. See resolve() for details.
     * @return  Change              provides fluent interface.
     * @throws  Exception           if the change is not a pending change.
     * @throws  ConflictException   if change contains files requiring resolve.
     */
    public function submit($description = null, $options = null)
    {
        // ensure change is a pending change.
        if (!$this->isPending()) {
            throw new Exception("Can only submit pending changes.");
        }

        // if description is given, use it.
        if (strlen($description)) {
            $this->setDescription($description);
        }

        // save the change before submit.
        $this->save();

        // try repeatedly to submit (with resolves in-between attempts)
        // note: no need to explicitly sync as submit schedules resolve
        for ($i = static::MAX_SUBMIT_ATTEMPTS; $i > 0; $i--) {
            try {
                $result = $this->getConnection()->run("submit", array("-c", $this->getId()));

                // everything went ok no need to retry.
                break;
            } catch (ConflictException $e) {
                // if there are no resolve options or we have exceeded
                // max resolve attempts; re-throw the resolve exception
                if ($i <= 1 || empty($options)) {
                    throw $e;
                }

                // our id might have changed - update our id field to match
                // note we avoid setId() as it can cause a populate()
                $this->values[static::ID_FIELD] = $e->getChange()->getId();

                $this->resolve($options);
            }
        }

        // extract change number, it could have noisy trigger output prior and/or
        // after the actual data so just scan till we find it.
        // note we avoid setId() as it can cause a populate()
        foreach ($result->getData() as $data) {
            if (is_array($data) && isset($data['submittedChange'])) {
                break;
            }
        }
        $this->values[static::ID_FIELD] = $data['submittedChange'];

        // we cache files and things - clear that out
        $this->cache = array();

        return $this;
    }

    /**
     * Revert all of the files in this changelist.
     *
     * @return  Change      provides fluent interface.
     * @throws  Exception   if the change is not a pending change.
     */
    public function revert()
    {
        // ensure change is a pending change.
        if (!$this->isPending()) {
            throw new Exception("Can only revert pending changes.");
        }

        // save the change before revert (updates files in change).
        $this->save();

        // perform revert.
        $result = $this->getConnection()->run(
            "revert",
            array("-c", $this->getId(), '//...')
        );

        // should re-populate.
        $this->deferPopulate(true);

        return $this;
    }


    /**
     * Delete this changelist.
     *
     * @param   bool        $force  optional - defaults to false - set to true to force delete of
     *                              another user/client's changelist or a submitted (empty) change.
     * @return  Change      provides fluent interface.
     */
    public function delete($force = false)
    {
        $id = $this->getId();
        if ($id === null) {
            throw new Exception("Cannot delete change. No id has been set.");
        }

        // default change cannot be deleted.
        if ($id === Change::DEFAULT_CHANGE) {
            throw new Exception("Cannot delete the default change.");
        }

        // ensure id exists.
        $connection = $this->getConnection();
        if (!static::exists($id, $connection)) {
            throw new NotFoundException(
                "Cannot delete change $id. Record does not exist."
            );
        }

        // unknown or unhandled change status (e.g. 'shelved').
        if (!$this->isPending() && !$this->isSubmitted()) {
            throw new Exception(
                "Unable to delete change with status '" . $this->getStatus() . "'."
            );
        }

        // handle submitted changes.
        $connection = $this->getConnection();
        if ($this->isSubmitted()) {

            // requires force option.
            if (!$force) {
                throw new Exception(
                    "Cannot delete a submitted change without the force option."
                );
            }

            // check for files.
            if (count($this->getFiles())) {
                throw new Exception(
                    "Cannot delete a submitted change that contains files."
                );
            }

            $result = $connection->run("change", array("-d", "-f", $id));
        }

        // handle pending changes (must remove files and fixes first).
        if ($this->isPending()) {
            if (!$force &&
                ($this->getUser()   !== $connection->getUser() ||
                 $this->getClient() !== $connection->getClient())) {
                throw new Exception(
                    "Cannot delete a change from another user/client without the force option."
                );
            }

            // remove any associated files or jobs.
            $change = clone $this;
            $change->setFiles(null)->setJobs(null)->save();

            // delete the change.
            $flags = array("-d", $id);
            if ($force) {
                array_unshift($flags, "-f");
            }
            $connection->run("change", $flags);
        }

        // confirm delete successful (change -d does not surface errors).
        if (static::exists($id)) {
            throw new Exception("Failed to delete change $id.");
        }

        // should re-populate.
        $this->deferPopulate(true);

        return $this;
    }

    /**
     * Resolves the change based on the passed option(s).
     *
     * You must specify one of the below:
     *  RESOLVE_ACCEPT_MERGED
     *   Automatically accept the Perforce-recom mended file revision:
     *   if theirs is identical to base, accept yours; if yours is identical
     *   to base, accept theirs; if yours and theirs are different from base,
     *   and there are no conflicts between yours and theirs; accept merge;
     *   other wise, there are conflicts between yours and theirs, so skip this file.
     *  RESOLVE_ACCEPT_YOURS
     *   Accept Yours, ignore theirs.
     *  RESOLVE_ACCEPT_THEIRS
     *   Accept Theirs. Use this flag with caution!
     *  RESOLVE_ACCEPT_SAFE
     *   Safe Accept. If either yours or theirs is different from base,
     *   (and the changes are in common) accept that revision. If both
     *   are different from base, skip this file.
     *  RESOLVE_ACCEPT_FORCE
     *   Force Accept. Accept the merge file no matter what. If the merge file
     *   has conflict markers, they will be left in, and you'll need to remove
     *   them by editing the file.
     *
     * Additionally, one of the following whitespace options can, optionally, be passed:
     *  IGNORE_WHITESPACE_CHANGES
     *   Ignore whitespace-only changes (for instance, a tab replaced by eight spaces)
     *  IGNORE_WHITESPACE
     *   Ignore whitespace altogether (for instance, deletion of tabs or other whitespace)
     *  IGNORE_LINE_ENDINGS
     *   Ignore differences in line-ending convention
     *
     * Lastly, the resolve can be limited to a particular file in the change by passing:
     *  RESOLVE_FILE => filespec with no wildcards
     *
     * @param   array|string    $options    Resolve option(s); must include a RESOLVE_* preference.
     * @return  Change          provide fluent interface.
     * @todo implement a way to accept edit
     */
    public function resolve($options)
    {
        if (is_string($options)) {
            $options = array($options);
        }

        if (!is_array($options)) {
            throw new \InvalidArgumentException('Expected a string or array of options.');
        }

        $mode       = '';
        $whitespace = '';
        $arguments  = array();

        // loop options so we accept the last mode
        // and whitespace setting we encounter.
        foreach ($options as $option) {
            switch ($option)
            {
                case static::RESOLVE_ACCEPT_MERGED:
                    $mode = '-am';
                    break;
                case static::RESOLVE_ACCEPT_YOURS:
                    $mode = '-ay';
                    break;
                case static::RESOLVE_ACCEPT_THEIRS:
                    $mode = '-at';
                    break;
                case static::RESOLVE_ACCEPT_SAFE:
                    $mode = '-as';
                    break;
                case static::RESOLVE_ACCEPT_FORCE:
                    $mode = '-af';
                    break;
            }

            switch ($option)
            {
                case static::IGNORE_WHITESPACE_CHANGES:
                    $whitespace = '-db';
                    break;
                case static::IGNORE_WHITESPACE:
                    $whitespace = '-dw';
                    break;
                case static::IGNORE_LINE_ENDINGS:
                    $whitespace = '-dl';
                    break;
            }
        }

        // we can't do anything without a mode; throw
        if (empty($mode)) {
            throw new \InvalidArgumentException(
                'No action specified. Expected Resolve Accept Merged|Yours|Theirs|Safe|Force'
            );
        }

        // compile our various flags into our arguments array
        $arguments[] = $mode;
        if ($whitespace) {
            $arguments[] = $whitespace;
        }

        $files = $this->getFiles();
        if (isset($options[static::RESOLVE_FILE])) {
            $file = $options[static::RESOLVE_FILE];
            if (!in_array($file, $files)) {
                throw new \InvalidArgumentException(
                    "The RESOLVE_FILE specified is not in this change."
                );
            }

            $files = array($file);
        }

        // resolve files in several (as few as possible) runs as
        // there is a potential to exceed the arg-max on this command
        $connection = $this->getConnection();
        $batches    = $connection->batchArgs($files, $arguments);
        foreach ($batches as $batch) {
            $connection->run('resolve', $batch);
        }

        return $this;
    }

    /**
     * Set the fix status on jobs attached with this change. It will become the job's
     * status when the change is submitted (thus we don't allow to set it on already
     * submitted changes).
     *
     * @param   string|null     $fixStatus  status to set on jobs when this change is submitted
     *                                      to defer to the default behaviour set the value to null
     * @return  Change                      provides fluent interface.
     * @throws  \InvalidArgumentException   if fix status is incorrect type
     * @throws  Exception                   if change is submitted
     */
    public function setFixStatus($fixStatus)
    {
        if (!is_string($fixStatus) && $fixStatus !== null) {
            throw new \InvalidArgumentException('Job fix status must be a string or null.');
        }

        // setting job's fix status makes sense only on unsubmitted changes
        if ($this->isSubmitted()) {
            throw new Exception('Cannot set job fix status on submitted changes.');
        }

        $this->fixStatus = $fixStatus;

        return $this;
    }

    /**
     * Get the date this change was last modified on the server.
     *
     * @return  null|string     the date this change was last modified on the server,
     *                          or null if the change does not exist on the server.
     * @todo    modify to use DateTime object.
     */
    public function getDate()
    {
        return $this->getRawValue('Date');
    }

    /**
     * Get the unixtime this change was last modified on the server.
     *
     * @return  int|null    the unixtime this change was last modified on the server,
     *                      or null if the change does not exist on the server.
     */
    public function getTime()
    {
        return static::dateToTime($this->getDate(), $this->getConnection()) ?: null;
    }

    /**
     * Get the user that created this change.
     *
     * @return  string  the user that created this change.
     */
    public function getUser()
    {
        $user = $this->getRawValue('User');
        if (!$user) {
            $user = $this->getConnection()->getUser();
        }
        return $user;
    }

    /**
     * Set the user that created this change.
     *
     * @param   $user   string|User         the user that created this change.
     * @return  Change  provides a fluent interface.
     * @throws  \InvalidArgumentException   if bad type given for user
     */
    public function setUser($user)
    {
        $user = $user instanceof User ? $user->getId() : $user;

        if ($user !== null && !is_string($user)) {
            throw new \InvalidArgumentException("Cannot set user. Invalid type given.");
        }

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

    /**
     * Get the client on which this change was created.
     *
     * @return  string  the client on which this change was created.
     */
    public function getClient()
    {
        $client = $this->getRawValue('Client');
        if (!$client) {
            $client = $this->getConnection()->getClient();
        }
        return $client;
    }

    /**
     * Set the client on this change.
     *
     * @param   string|Client|null  $client     the client to set on this change
     * @return  Change              provides a fluent interface.
     * @throws  \InvalidArgumentException       if bad type given for client
     */
    public function setClient($client)
    {
        // normalize input to a string if object given
        if ($client instanceof Client) {
            $client = $client->getId();
        }

        if ($client !== null && !is_string($client)) {
            throw new \InvalidArgumentException("Cannot set client. Invalid type given.");
        }

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

    /**
     * Get the status of this change (either 'pending' or 'submitted').
     *
     * @return  string  the status of this change: 'pending', 'submitted'.
     */
    public function getStatus()
    {
        $status = $this->getRawValue('Status');
        if (!$status) {
            $status = static::PENDING_CHANGE;
        }
        return $status;
    }

    /**
     * Get the type of this change (either 'public' or 'restricted').
     *
     * @return  string  the type of this change: 'public' or 'restricted'.
     */
    public function getType()
    {
        $type = $this->hasField('Type') ? $this->getRawValue('Type') : false;
        if (!$type) {
            $type = static::PUBLIC_CHANGE;
        }
        return $type;
    }

    /**
     * Set the type of this change (either 'public' or 'restricted').
     *
     * @return  Change  provides fluent interface
     */
    public function setType($type)
    {
        if ($type != static::PUBLIC_CHANGE && $type !== static::RESTRICTED_CHANGE) {
            throw new \InvalidArgumentException("Cannot set type. Type must be public or restricted.");
        }

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

    /**
     * Get the description for this change.
     *
     * @return  string  the description for this change.
     */
    public function getDescription()
    {
        return $this->getRawValue('Description');
    }

    /**
     * Set the description for this change.
     *
     * @param   string|null     $description    description for this change.
     * @return  Change          provides a fluent interface.
     * @throws  \InvalidArgumentException if description is incorrect type.
     */
    public function setDescription($description)
    {
        if ($description !== null && !is_string($description)) {
            throw new \InvalidArgumentException("Cannot set description. Invalid type given.");
        }

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

    /**
     * Get the job status of this change (the status that associated jobs will
     * have when the change is submitted).
     *
     * The value of the job status field is not preserved. You cannot get the
     * job status of a saved or submitted change. Once a changelist is saved or
     * submitted, the job status field is cleared. It can only be read after it
     * has been explicitly set, and before the change is saved or submitted.
     *
     * @return  null|string     the job status of this change if not yet saved or submitted.
     *                          null otherwise.
     */
    public function getJobStatus()
    {
        return $this->getRawValue('JobStatus');
    }

    /**
     * Get the jobs attached to this change.
     *
     * @return  array   the list of jobs attached to this change.
     * @todo    return  Job objects in an iterator.
     */
    public function getJobs()
    {
        $jobs = $this->getRawValue('Jobs');
        return is_array($jobs) ? $jobs : array();
    }

    /**
     * Get the jobs attached to this change in Job format.
     *
     * @return  FieldedIterator     list of Job's associated with this change.
     */
    public function getJobObjects()
    {
        // just skip to an empty iterator if we have no jobs
        if (!$this->getJobs()) {
            return new FieldedIterator;
        }

        if (!isset($this->cache['jobObjects'])
            || !$this->cache['jobObjects'] instanceof FieldedIterator
        ) {
            $this->cache['jobObjects'] = Job::fetchAll(
                array(Job::FETCH_BY_IDS => $this->getJobs()),
                $this->getConnection()
            );
        }

        return clone $this->cache['jobObjects'];
    }

    /**
     * Get the requested job attached to this change in Job format.
     *
     * @param   string  $job    Job identifier
     * @return  Job             The requested job
     * @throws  \InvalidArgumentException   If the specified job isn't attached to this change.
     */
    public function getJobObject($job)
    {
        // validate input
        if (!is_string($job)) {
            throw new \InvalidArgumentException('Job must be a string or P4\Job object.');
        }

        foreach ($this->getJobObjects() as $jobObject) {
            if ($jobObject->getId() == $job) {
                return $jobObject;
            }
        }

        throw new \InvalidArgumentException('The requested job was not found in this change');
    }

    /**
     * Set the list of jobs attached to this change.
     *
     * @param   null|array|FieldedIterator  $jobs   the jobs to attach to this change.
     * @return  Change                      provides a fluent interface.
     * @throws  \InvalidArgumentException   if jobs is incorrect type.
     * @throws  Exception                   if change is submitted.
     */
    public function setJobs($jobs)
    {
        if ($jobs === null) {
            $jobs = array();
        }

        // normalize to an array
        if ($jobs instanceof FieldedIterator) {
            $jobs = $jobs->toArray(true);
        }

        // ensure jobs is an array.
        if (!is_array($jobs)) {
            throw new \InvalidArgumentException('Cannot set jobs. Invalid type given.');
        }

        // ensure job elements are strings or job objects.
        foreach ($jobs as $key => $job) {
            if ($job instanceof Job) {
                $jobs[$key] = $job = $job->getId();
            }

            if (!is_string($job)) {
                throw new \InvalidArgumentException('Each job must be a string.');
            }
        }

        // don't permit set jobs on submitted changes.
        if ($this->isSubmitted()) {
            throw new Exception('Cannot set jobs on a submitted change.');
        }

        // we cache job objects; clear that out
        $this->cache = array();

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

    /**
     * Add a job to the list of jobs attached to this change.
     *
     * @param   string      $job    the id of the job to attach to this change.
     * @return  Change      provides fluent interface.
     */
    public function addJob($job)
    {
        $jobs = $this->getJobs();
        if (!in_array($job, $jobs)) {
            $jobs[] = $job;
        }
        $this->setJobs($jobs);

        return $this;
    }

    /**
     * Get the files attached to this change. Revspecs are included for submitted
     * changes but are not present on pending changes.
     *
     * @return  array   list of files associated with this change.
     */
    public function getFiles()
    {
        $files = $this->getRawValue('Files');
        return is_array($files) ? $files : array();
    }

    /**
     * Get the files attached to this change in File format.
     *
     * @return  FieldedIterator     list of File's associated with this change.
     */
    public function getFileObjects()
    {
        if (!isset($this->cache['fileObjects'])
            || !$this->cache['fileObjects'] instanceof FieldedIterator
        ) {
            $this->cache['fileObjects'] = File::fetchAll(
                FileQuery::create()->addFilespecs(
                    $this->getFiles()
                ),
                $this->getConnection()
            );
        }

        return clone $this->cache['fileObjects'];
    }

    /**
     * For numbered changes, it is possible to get additional information
     * about the files attached to the change (e.g. action, type, rev, from-file).
     *
     * This method is a useful alternative to getFileObjects() if you don't
     * want to incur the cost of an fstat query and the instantiation of files.
     *
     * @param   bool            $shelved    optional - get shelved files instead of open files (default false)
     * @param   int|false|null  $max        optional - limit number of files to report (optimized in 2014.1+)
     *                                      if set to false, return any pre-existing cached file data
     * @return  array           a list of files with metadata (e.g. action, type, rev, from-file)
     */
    public function getFileData($shelved = false, $max = null)
    {
        $cacheKeyPrefix = 'fileData-' . ($shelved ? 'shelved-' : '');

        // if max is explicitly false, caller doesn't care how many files we return
        // pick the cached result with the highest number of files
        if ($max === false) {
            foreach (array_keys($this->cache) as $cacheKey) {
                if (strpos($cacheKey, $cacheKeyPrefix) === 0) {
                    $max = max($max, end(explode('-', $cacheKey)));
                }
            }
        }

        $max = (int) $max;

        $cacheKey = $cacheKeyPrefix . $max;
        if (!isset($this->cache[$cacheKey])) {
            // note: we don't supply '-s' to suppress diffs because this breaks
            // fromFile information - fortunately, we don't need -s because we
            // are getting tagged output which suppresses diffs automatically
            // (see job058799 for more information).
            $flags = $max && $this->getConnection()->isServerMinVersion('2014.1') ? array('-m', $max) : array();
            $flags = array_merge($flags, $shelved ? array('-S', $this->getId()) : array($this->getId()));
            $data  = $this->getConnection()->run('describe', $flags)->expandSequences()->getData(0);

            // turn describe data inside-out (currently keyed on field name
            // with entries for each file, re-key by file, then field).
            $files = array();
            foreach ($data as $key => $values) {
                // skip over job/jobstat as they aren't file related
                if (strpos($key, 'job') === 0) {
                    continue;
                }

                if (is_array($values)) {
                    foreach ($values as $index => $value) {
                        if ($max && $index >= $max) {
                            break;
                        }
                        $files[$index] = isset($files[$index])
                            ? $files[$index] + array($key => $value)
                            : array($key => $value);
                    }
                }

                // record the path so we can provide it later via getPath().
                // the 'path' is the deepest path common to all files.
                if ($key === 'path') {
                    $this->cache['path'] = $values;
                }

                // record the 'oldChange' so we can provide it later via
                // getOriginalId() - old change is the pre-submit id.
                if ($key === 'oldChange') {
                    $this->cache['oldChange'] = $values;
                }

                // record the 'shelved' property so we can use it later
                // needed to detect remote/un-promoted/shelved changes
                if ($key === 'shelved') {
                    $this->cache['shelved'] = true;
                }
            }

            // if we didn't encounter a 'shelved' property, it must be false
            if (!isset($this->cache['shelved'])) {
                $this->cache['shelved'] = false;
            }

            $this->cache[$cacheKey] = $files;
        }

        return $this->cache[$cacheKey];
    }

    /**
     * Get p4 changes list data for a submitted change.
     *
     * This is useful because changes reports some information that is not
     * included in spec data; specifically path and oldChange. The describe
     * command also reports this data (accessible via getFileData()), but
     * it includes all files in the change which can be too much data.
     *
     * @return  array   $data   p4 changes data from Perforce.
     * @throws  Exception       if called on a pending change.
     */
    public function getChangesData()
    {
        // not available for pending changes.
        if ($this->isPending()) {
            throw new Exception("Cannot get changes data for a pending change.");
        }

        if (!isset($this->cache['changesData'])) {
            $this->cache['changesData'] = $this->getConnection()->run(
                'changes',
                array('@=' . $this->getId())
            )->getData(0);
        }

        return $this->cache['changesData'];
    }

    /**
     * Get the deepest path common to all files in this change.
     *
     * @param   bool    $trim       remove trailing wildcards and partial paths
     * @param   int     $maxFiles   optional - limit number of files to scan (can cause incorrect result)
     * @return  string  the deepest path common to all files.
     */
    public function getPath($trim = true, $maxFiles = null)
    {
        // if the path is not cached and this change is
        // submitted, use p4 changes data to get path
        if (!array_key_exists('path', $this->cache) && $this->isSubmitted()) {
            $data = $this->getChangesData();
            $this->cache['path'] = $data['path'];
        }

        // if path is still not set, use file data (which primes path)
        if (!array_key_exists('path', $this->cache)) {
            $paths = $this->getFileData(false, $maxFiles);

            // compute the common path if it wasn't sent by the server.
            // the server doesn't report the path for shelved changes.
            // note: because the paths are sorted, we can get away with
            // a clever trick and just compare the first and last file.
            if (!array_key_exists('path', $this->cache) && count($paths)) {
                $first   = $paths[0]['depotFile'];
                $last    = $paths[count($paths) - 1]['depotFile'];
                $length  = min(strlen($first), strlen($last));
                $compare = $this->getConnection()->isCaseSensitive() ? 'strcmp' : 'strcasecmp';
                for ($i = 0; $i < $length; $i++) {
                    if ($compare($first[$i], $last[$i]) !== 0) {
                        break;
                    }
                }
                $this->cache['path'] = substr($first, 0, $i);
            } elseif (!array_key_exists('path', $this->cache)) {
                $this->cache['path'] = null;
            }
        }

        $path = $this->cache['path'];
        return $trim ? substr($path, 0, strrpos($path, '/')) : $path;
    }

    /**
     * Get the original change id. When a change is submitted it is
     * potentially renumbered to be the highest numbered change.
     * If the change is not renumbered, returns getId().
     *
     * @return  int     the original id, falling back to the current id
     */
    public function getOriginalId()
    {
        // if the original id is not cached and this change is
        // submitted, use p4 changes data to get original id
        if (!array_key_exists('oldChange', $this->cache) && $this->isSubmitted()) {
            $data = $this->getChangesData();
            $this->cache['oldChange'] = isset($data['oldChange']) ? $data['oldChange'] : null;
        }

        return isset($this->cache['oldChange'])
            ? $this->cache['oldChange']
            : $this->getId();
    }

    /**
     * Get the requested file attached to this change in File format.
     *
     * @param   File|string     $file       Filespec in string or File format; rev is ignored
     * @return  File            The requested file at the revision associated with this change
     * @throws  \InvalidArgumentException   If the specified file doesn't exist.
     */
    public function getFileObject($file)
    {
        // normalize to string
        if ($file instanceof File) {
            $file = $file->getDepotFilename();
        }

        // validate input
        if (!is_string($file)) {
            throw new \InvalidArgumentException('File must be a string or P4\File object.');
        }

        // ensure no rev-spec is present on our comparison entry
        $file = File::stripRevspec($file);
        foreach ($this->getFileObjects() as $changeFile) {
            if (File::stripRevspec($changeFile->getDepotfilename()) == $file) {
                return $changeFile;
            }
        }

        throw new \InvalidArgumentException('The requested file was not found in this change');
    }

    /**
     * Set the list of opened files attached to this change.
     *
     * @param   null|array|FieldedIterator  $files  the files to attach to this change.
     * @return  Change                      provides a fluent interface.
     * @throws  \InvalidArgumentException   if files is incorrect type.
     * @throws  Exception                   if change is submitted.
     */
    public function setFiles($files)
    {
        if ($files === null) {
            $files = array();
        }

        if ($files instanceof FieldedIterator) {
            $files = iterator_to_array($files);
        }

        // ensure files is an array.
        if (!is_array($files)) {
            throw new \InvalidArgumentException('Cannot set files. Invalid type given.');
        }

        // normalize the array entries to a string, stripping revspecs
        foreach ($files as &$file) {
            if ($file instanceof File) {
                $file = $file->getFilespec();
            }

            if (!is_string($file)) {
                throw new \InvalidArgumentException('All files must be a string or P4\File');
            }

            $file = File::stripRevspec($file);
        }

        // don't permit set files on submitted changes.
        if ($this->isSubmitted()) {
            throw new Exception('Cannot set files on a submitted change.');
        }

        // we cache file objects; clear that out
        $this->cache = array();

        $this->setRawValue('Files', $files);

        return $this;
    }

    /**
     * Add a file to the list of files in this changelist.
     *
     * @param   string|File     $file   the file to attach to this change.
     * @return  Change          provides fluent interface.
     */
    public function addFile($file)
    {
        // if file is a File object, extract the filespecs.
        if ($file instanceof File) {
            $file = $file->getFilespec();
        }

        $files = $this->getFiles();
        if (!in_array($file, $files)) {
            $files[] = $file;
        }
        $this->setFiles($files);

        return $this;
    }

    /**
     * Check if this is a pending change.
     *
     * @return  bool  true if this is a pending change - false otherwise.
     */
    public function isPending()
    {
        return ($this->isDefault() || $this->getStatus() === static::PENDING_CHANGE);
    }

    /**
     * Check if this is a submitted change.
     *
     * @return  bool  true if this is a submitted change - false otherwise.
     */
    public function isSubmitted()
    {
        return ($this->getStatus() === static::SUBMITTED_CHANGE);
    }

    /**
     * Test if this is the default change.
     *
     * @return  bool  true if this is the default change - false otherwise.
     */
    public function isDefault()
    {
        return ($this->getId() === static::DEFAULT_CHANGE);
    }

    /**
     * Test if this change is accessible. Returns false if the change is
     * restricted and the description is obfuscated to indicate user
     * has no permission to view it, true otherwise.
     *
     * @return  bool    false if this change is not accessible, true
     *                  otherwise
     */
    public function canAccess()
    {
        return $this->getType() !== static::RESTRICTED_CHANGE
            || trim($this->getDescription()) !== "<description: restricted, no permission to view>";
    }

    /**
     * Get files that need to be resolved.
     *
     * @return  FieldedIterator   files that need to be resolved.
     */
    public function getFilesToResolve()
    {
        $query = FileQuery::create()
                 ->addFilespec(File::ALL_FILES)
                 ->setLimitToChangelist($this->getId())
                 ->setLimitToNeedsResolve(true)
                 ->setLimitToOpened(true);
        return File::fetchAll($query, $this->getConnection());
    }

    /**
     * Get files that must be reverted.
     *
     * @return  FieldedIterator   files that must be reverted.
     */
    public function getFilesToRevert()
    {
        // setup fstat filter to match files that must be reverted.
        // several conditions to catch:
        //  - files open for add, but already existing and not deleted, or
        //  - files that are not open for add, but are deleted in the depot.
        $filter = "(action=add & headAction=* & ^headAction=...deleted) | "
                . "(headAction=...delete & ^action=add)";

        $query = FileQuery::create()
                 ->addFilespec(File::ALL_FILES)
                 ->setLimitToChangelist($this->getId())
                 ->setLimitToOpened(true)
                 ->setFilter($filter);
        return File::fetchAll($query, $this->getConnection());
    }

    /**
     * Get a field's raw value.
     * Extend parent to ensure the Description's placeholder value is translated to null.
     *
     * @param   string      $field  the name of the field to get the value of.
     * @return  mixed       the value of the field.
     * @throws  Exception   if the field does not exist.
     */
    public function getRawValue($field)
    {
        $value = parent::getRawValue($field);

        // if the Description field has its placeholder value just return null
        // you can't actually save the placeholder value its reserved.
        if ($field == 'Description' && $value == "<enter description here>\n") {
            return null;
        }

        return $value;
    }

    /**
     * Set a field's raw value (avoids mutator).
     * Extended to limit to allow setting client. We think its read only based
     * on the spec definition but it really isn't.
     *
     * @param   string  $field      the name of the field to set the value of.
     * @param   mixed   $value      the value to set in the field.
     * @return  SingularAbstract    provides a fluent interface
     * @throws  Exception           if the field does not exist or is read-only
     */
    public function setRawValue($field, $value)
    {
        if ($field == 'Client') {
            $this->values[$field] = $value;
            return $this;
        }

        return parent::setRawValue($field, $value);
    }

    /**
     * Attempt to detect if this change is a un-promoted shelved change on a remote edge server.
     *
     * We cannot do this with 100% accuracy, but we can make an educated guess.
     * If we satisfy the following conditions it is likely a remote edge shelf:
     *  - server version > 2014.1
     *  - not promoted
     *  - shelved change
     *  - no files
     *
     * @return  bool    true if the change looks like a remote edge shelf
     */
    public function isRemoteEdgeShelf()
    {
        // if the server is older than 2014.1, there is no such thing
        if (!$this->getConnection()->isServerMinVersion('2014.1')) {
            return false;
        }

        // if the change is promoted, then it is global
        if ($this->hasField('IsPromoted') && $this->get('IsPromoted') == 1) {
            return false;
        }

        // we need file data (describe output) for this next part
        $files = $this->getFileData(true, false);

        // if the change is not shelved, it cannot be an edge shelf
        if (!$this->cache['shelved']) {
            return false;
        }

        // if we can see files in the change, it cannot be a remote edge shelf
        if (count($files)) {
            return false;
        }

        // hmmm... looks inaccessible, likely a remote edge shelf
        return true;
    }

    /**
     * Check if the given id is in a valid format for a change number.
     *
     * @param   string      $id     the id to check
     * @return  bool        true if id is valid, false otherwise
     */
    protected static function isValidId($id)
    {
        $validator = new Validate\ChangeNumber;
        return $validator->isValid($id);
    }

    /**
     * Get raw spec data direct from Perforce. No caching involved.
     * Overrides parent to suppress id when id is 'default' and to
     * fetch files for submitted changes.
     *
     * @return  array   $data   the raw spec output from Perforce.
     * @todo    get jobs for submitted changes.
     */
    protected function getSpecData()
    {
        $flags = array('-o');
        if ($this->getId() !== static::DEFAULT_CHANGE) {
            $flags[] = $this->getId();
        }
        $data = $this->getConnection()
                     ->run(static::SPEC_TYPE, $flags)
                     ->expandSequences()
                     ->getData(-1);

        // get files if this is a submitted change
        // note: can't use isSubmitted here - populate not complete yet.
        if ($data['Status'] == Change::SUBMITTED_CHANGE) {
            $describe = $this->getConnection()
                             ->run('describe', array('-s', $this->getId()))
                             ->getData(0);

            $data['Files'] = array();
            for ($i = 0; isset($describe['depotFile' . $i], $describe['rev' . $i]); $i++) {
                $data['Files'][] = $describe['depotFile' . $i] . '#' . $describe['rev' . $i];
            }
        }

        return $data;
    }

    /**
     * Produce set of flags for the spec list command, given fetch all options array.
     * Extends parent to add support for additional options.
     *
     * @param   array   $options    array of options to augment fetch behavior.
     *                              see fetchAll for documented options.
     * @return  array   set of flags suitable for passing to spec list command.
     */
    protected static function getFetchAllFlags($options)
    {
        $flags = parent::getFetchAllFlags($options);

        // always use -l (for full descriptions).
        $flags[] = "-l";

        if (isset($options[static::FETCH_INTEGRATED]) &&
            $options[static::FETCH_INTEGRATED] === true) {
            $flags[] = "-i";
        }

        if (isset($options[static::FETCH_BY_STATUS])) {
            $flags[] = "-s";
            $flags[] = $options[static::FETCH_BY_STATUS];
        }

        if (isset($options[static::FETCH_BY_CLIENT])) {
            $flags[] = "-c";
            $flags[] = $options[static::FETCH_BY_CLIENT];
        }

        if (isset($options[static::FETCH_BY_USER])) {
            $flags[] = "-u";
            $flags[] = $options[static::FETCH_BY_USER];
        }

        if (isset($options[static::FETCH_BY_IDS]) && is_array($options[static::FETCH_BY_IDS])) {
            foreach ($options[static::FETCH_BY_IDS] as $id) {
                $flags[] = '@' . $id . ',@' . $id;
            }
        }

        // filespec must come last.
        if (isset($options[static::FETCH_BY_FILESPEC]) && $options[static::FETCH_BY_FILESPEC]) {
            $flags[] = $options[static::FETCH_BY_FILESPEC];
        }

        return $flags;
    }

    /**
     * Given a spec entry from spec list output (p4 changes), produce
     * an instance of this spec with field values set where possible.
     *
     * @param   array                       $listEntry      a single spec entry from spec list output.
     * @param   array                       $flags          the flags that were used for this 'fetchAll' run.
     * @param   ConnectionInterface         $connection     a specific connection to use.
     * @return  Change                      a (partially) populated instance of this spec class.
     */
    protected static function fromSpecListEntry($listEntry, $flags, ConnectionInterface $connection)
    {
        // rename 'desc' field to 'Description'.
        $listEntry['Description'] = $listEntry['desc'];
        unset($listEntry['desc']);

        $change = parent::fromSpecListEntry($listEntry, $flags, $connection);

        // record the 'path' so we can provide it later via getPath().
        // record the 'oldChange' so we can provide it later via getOriginalId().
        $change->cache['path']      = isset($listEntry['path'])      ? $listEntry['path']      : null;
        $change->cache['oldChange'] = isset($listEntry['oldChange']) ? $listEntry['oldChange'] : null;

        return $change;
    }
}