<?php
/**
 * Abstracts operations against Perforce streams.
 *
 * @copyright   2011 Perforce Software. All rights reserved.
 * @license     Please see LICENSE.txt in top-level folder of this distribution.
 * @version     <release>/<patch>
 * @todo add accessor/mutator for 'Paths'
 */

namespace P4\Spec;

use P4\Validate;
use P4\Connection\ConnectionInterface;
use P4\Connection\Exception\CommandException;
use P4\Model\Fielded\Iterator as FieldedIterator;

class Stream extends PluralAbstract
{
    const SPEC_TYPE         = 'stream';
    const ID_FIELD          = 'Stream';

    const FETCH_BY_PATH     = 'path';
    const FETCH_BY_FILTER   = 'filter';
    const SORT_RECURSIVE    = 'sort';

    protected $cache        = array();
    protected $fields       = array(
        'Update'        => array(
            'accessor'  => 'getUpdateDateTime'
        ),
        'Access'        => array(
            'accessor'  => 'getAccessDateTime'
        ),
        'Owner'         => array(
            'accessor'  => 'getOwner',
            'mutator'   => 'setOwner'
        ),
        'Name'          => array(
            'accessor'  => 'getName',
            'mutator'   => 'setName'
        ),
        'Parent'        => array(
            'accessor'  => 'getParent',
            'mutator'   => 'setParent'
        ),
        'Type'          => array(
            'accessor'  => 'getType',
            'mutator'   => 'setType'
        ),
        'Description'   => array(
            'accessor'  => 'getDescription',
            'mutator'   => 'setDescription'
        ),
        'Options'       => array(
            'accessor'  => 'getOptions',
            'mutator'   => 'setOptions'
        ),
        'Paths'         => array(
            'accessor'  => 'getPaths',
            'mutator'   => 'setPaths'
        )
    );

    /**
     * Get all Streams from Perforce. Adds the following filter options:
     *   FETCH_BY_PATH - limits results to streams matching path (can include wildcards).
     * FETCH_BY_FILTER - a 'jobview' style expression to limit results.
     *  SORT_RECURSIVE - optionally sort the results recusively
     *
     * @param   array                   $options        optional - options to augment fetch behavior.
     * @param   ConnectionInterface     $connection     optional - a specific connection to use.
     * @return  FieldedIterator         all records of this type matching options.
     */
    public static function fetchAll($options = array(), ConnectionInterface $connection = null)
    {
        // try/catch parent to deal with the exception we get on non-existend depots
        try {
            $streams = parent::fetchAll($options, $connection);

            // set the 'parent' stream on each entry if we have a copy
            foreach ($streams as $stream) {
                // skip any streams without parents
                if (!$stream->getParent()) {
                    continue;
                }

                // attempt to locate the parent object in our result set and set it
                $parent = $streams->filter('Stream', $stream->getParent(), FieldedIterator::FILTER_COPY)
                                  ->first();

                if ($parent) {
                    $stream->cache['parentObject'] = $parent;
                }
            }

            // apply sorting if it has been requested
            if (isset($options[static::SORT_RECURSIVE]) && $options[static::SORT_RECURSIVE]) {
                $streams = static::sortRecursively($streams);
            }

            return $streams;
        } catch (CommandException $e) {
            // if the 'depot' has been interpreted as an invalid client, just return no matches
            if (preg_match("/Command failed: .+ - must refer to client/", $e->getMessage())) {
                return new FieldedIterator;
            }

            // unexpected error; rethrow it
            throw $e;
        }
    }

    /**
     * Determine if the given stream 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 stream.
     */
    public static function exists($id, ConnectionInterface $connection = null)
    {
        // check id for valid format
        if (!static::isValidId($id)) {
            return false;
        }

        $streams = static::fetchAll(array(static::FETCH_BY_FILTER => "Stream=$id"), $connection);

        return (bool) count($streams);
    }

    /**
     * Get the last update time for this stream.
     * This value is read only, no setUpdateDateTime function is provided.
     *
     * If this is a brand new stream, null will be returned in lieu of a time.
     *
     * @return  string|null  Date/Time of last update, formatted "2009/11/23 12:57:06" or null
     */
    public function getUpdateDateTime()
    {
        return $this->getRawValue('Update');
    }

    /**
     * Get the last access time for this stream.
     * This value is read only, no setAccessDateTime function is provided.
     *
     * If this is a brand new spec, null will be returned in lieu of a time.
     *
     * @return  string|null  Date/Time of last access, formatted "2009/11/23 12:57:06" or null
     */
    public function getAccessDateTime()
    {
        return $this->getRawValue('Access');
    }

    /**
     * Get the owner of this stream.
     *
     * @return  string|null     User who owns this record.
     */
    public function getOwner()
    {
        return $this->getRawValue('Owner');
    }

    /**
     * Set the owner of this stream to passed value.
     *
     * @param   string|null $owner  A string containing username
     * @return  Stream      provides a fluent interface.
     * @throws  \InvalidArgumentException   Owner is incorrect type.
     */
    public function setOwner($owner)
    {
        if (!is_string($owner) && !is_null($owner)) {
            throw new \InvalidArgumentException('Owner must be a string or null.');
        }

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

    /**
     * Get the name setting for this stream.
     *
     * @return  string|null     Name set for this stream or null.
     */
    public function getName()
    {
        return $this->getRawValue('Name');
    }

    /**
     * Set the name for this stream.
     *
     * @param   string|null $name   Name for this stream or null
     * @return  Stream      provides a fluent interface.
     * @throws  \InvalidArgumentException   Name is incorrect type.
     */
    public function setName($name)
    {
        if (!is_string($name) && !is_null($name)) {
            throw new \InvalidArgumentException('Name must be a string or null.');
        }

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

    /**
     * Get the parent setting for this stream.
     *
     * @return  string|null Parent set for this stream.
     */
    public function getParent()
    {
        $parent = $this->getRawValue('Parent');

        return $parent == 'none' ? null : $parent;
    }

    /**
     * Set the parent for this stream.
     *
     * @param   string|null $parent     Parent for this stream or null
     * @return  Stream      provides a fluent interface.
     * @throws  \InvalidArgumentException   Parent is incorrect type.
     */
    public function setParent($parent)
    {
        if (!is_string($parent) && !is_null($parent)) {
            throw new \InvalidArgumentException('Parent must be a string or null.');
        }

        // clear cache as parent may have changed
        $this->cache = array();

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

    /**
     * Get the parent asociated with this stream in Stream format.
     *
     * @return  Stream|null     this streams parent object or null if none
     */
    public function getParentObject()
    {
        if (!$this->getParent()) {
            return null;
        }

        if (!isset($this->cache['parentObject'])
            || !$this->cache['parentObject'] instanceof Stream
        ) {
            $this->cache['parentObject'] = Stream::fetch(
                $this->getParent(),
                $this->getConnection()
            );
        }

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

    /**
     * Returns the depth of this stream. Assumes all parent objects
     * are returnable.
     *
     * @return  int     the depth of this stream.
     */
    public function getDepth()
    {
        $stream = $this;
        for ($i = 0; $stream = $stream->getParentObject(); $i++) {
        }

        return $i;
    }

    /**
     * Get the type setting for this stream.
     *
     * @return  string|null Type set for this stream.
     */
    public function getType()
    {
        return $this->getRawValue('Type');
    }

    /**
     * Set the type for this stream.
     *
     * @param   string|null $type   Type for this stream or null
     * @return  Stream      provides a fluent interface.
     * @throws  \InvalidArgumentException Type is incorrect type.
     */
    public function setType($type)
    {
        if (!is_string($type) && !is_null($type)) {
            throw new \InvalidArgumentException('Type must be a string or null.');
        }

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

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

    /**
     * Set a description for this stream.
     *
     * @param   string|null $description    description for this stream.
     * @return  Stream      provides a fluent interface.
     * @throws  \InvalidArgumentException   Description is incorrect type.
     */
    public function setDescription($description)
    {
        if (!is_string($description) && !is_null($description)) {
            throw new \InvalidArgumentException('Description must be a string or null.');
        }

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

    /**
     * Get options for this stream.
     * Returned array will contain one option per element e.g.:
     * array (
     *     0 => 'allsubmit',
     *     1 => 'toparent',
     *     2 => 'unlocked'
     * )
     *
     * @return  array  options which are set on this stream.
     */
    public function getOptions()
    {
        $options = $this->getRawValue('Options');
        $options = explode(' ', $options);

        // Explode will set key 0 to null for empty input; clean it up.
        if (count($options) == 1 && empty($options[0])) {
            $options = array();
        }

        return $options;
    }

    /**
     * Set the options for this stream.
     * Accepts an array, format detailed in getOptions, or a single string containing
     * a space seperated list of options.
     *
     * @param   array|string    $options    options to set on this stream in array or string.
     * @return  Steam       provides a fluent interface.
     * @throws  \InvalidArgumentException   Options are incorrect type.
     */
    public function setOptions($options)
    {
        if (is_array($options)) {
            $options = implode(' ', $options);
        }

        if (!is_string($options)) {
            throw new \InvalidArgumentException('Options must be an array or string');
        }

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

    /**
     * Get the paths for this stream.
     * Path entries will be returned as an array with 'type', 'view' and 'depot' entries, e.g.:
     * array (
     *      0 => array (
     *          'type'  => 'share',
     *          'view'  => 'src/...',
     *          'depot' => null
     *      )
     *      1 => array (
     *          'type'  => 'import',
     *          'view'  => 'src/...',
     *          'depot' => '//over/there/src/...'
     *      )
     *  )
     *
     * @return  array  list path entries for this stream.
     */
    public function getPaths()
    {
        // The raw path data is formatted as:
        //  array (
        //      0 => 'share ...',
        //      1 => 'import imp/ //depot/other/local/...'
        //  )

        $paths = array();
        foreach ($this->getRawValue('Paths') ?: array() as $entry) {
            $entry = str_getcsv($entry, ' ');
            $paths[] = array_combine(
                array('type', 'view', 'depot'),
                $entry + array(null, null, null)
            );
        }

        return $paths;
    }

    /**
     * Set the paths for this stream.
     * Paths are passed as an array of path entries. Each patj entry can be an array with
     * 'type', 'view' and, optionally, 'depot' entries or a raw string.
     *
     * @param   array|string    $paths  Path entries, formatted as sub-arrays or strings.
     * @return  Stream          provides a fluent interface.
     * @throws  \InvalidArgumentException Paths array, or a path entry, is incorrect type.
     */
    public function setPaths($paths)
    {
        // we let the caller pass in a single path and normalize it below
        if (is_string($paths) || isset($paths['type'], $paths['view'])) {
            $paths = array($paths);
        }

        if (!is_array($paths)) {
            throw new \InvalidArgumentException('Paths must be passed as array or string.');
        }

        // The Paths array contains either:
        // - Child arrays keyed on type/view/[depot] which we glue together
        // - Raw strings which we simply leave as is
        // The below foreach run will normalize the whole thing for storage
        $parsedPaths = array();
        foreach ($paths as $path) {
            if (is_array($path)
                && isset($path['type'], $path['view'])
                && is_string($path['type'])
                && is_string($path['view'])
                && (!isset($path['depot']) || is_string($path['depot']))
            ) {
                // stringify the path quoting paths to be safe
                $string = $path['type'] . ' "' . $path['view'] . '"';
                if (isset($path['depot']) && strlen($path['depot'])) {
                    $string .= ' "' . $path['depot'] . '"';
                }

                $path = $string;
            }

            if (!is_string($path)) {
                throw new \InvalidArgumentException(
                    "Each path entry must be an array with type/view (and optionally depot) or a string."
                );
            }

            $validate = str_getcsv($path, ' ');
            if (count($validate) < 2 || count($validate) > 3
                || trim($validate[0]) === '' || trim($validate[1]) === ''
            ) {
                throw new \InvalidArgumentException(
                    "Each path entry must contain between two and three entries."
                );
            }

            $parsedPaths[] = $path;
        };

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

    /**
     * Add a path to this stream.
     *
     * @param   string      $type   the path type (share/isolate/import/exclude)
     * @param   string      $view   the view path
     * @param   string|null $depot  the depot path (only used for import type)
     * @return  Stream      provides a fluent interface.
     */
    public function addPath($type, $view, $depot = null)
    {
        $paths   = $this->getPaths();
        $paths[] = array("type" => $type, "view" => $view, "depot" => $depot);

        return $this->setPaths($paths);
    }

    /**
     * Save this spec to Perforce.
     * Extends parent to provide a default value of 'none' for parent.
     *
     * @return  Stream      provides a fluent interface
     */
    public function save()
    {
        if (!$this->get('Parent')) {
            $this->set('Parent', 'none');
        }

        return parent::save();
    }

    /**
     * Remove this stream. Extend parent to remove all clients dedicated to this
     * stream first.
     *
     * @param   boolean     $force      pass true to force delete this stream, additionally
     *                                  attempts to delete any clients using this stream
     *                                  by default stream will only be deleted if there are
     *                                  no clients current using the stream and current user
     *                                  is the stream owner or the stream is unlocked.
     * @return  Stream      provides fluent interface.
     */
    public function delete($force = false)
    {
        // remove clients dedicated to this stream if force is true
        if ($force) {
            Client::fetchAll(
                array(Client::FETCH_BY_STREAM => $this->getId())
            )->invoke('delete', array($force));
        }

        return parent::delete($force ? array('-f') : null);
    }

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

    /**
     * Produce set of flags for the spec list command, given fetch all options array.
     * Extends parent to add support for filter option.
     *
     * @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);

        if (isset($options[static::FETCH_BY_FILTER])) {
            $filter = $options[static::FETCH_BY_FILTER];

            if (!is_string($filter) || trim($filter) === '') {
                throw new \InvalidArgumentException(
                    'Filter expects a non-empty string as input'
                );
            }

            $flags[] = '-F';
            $flags[] = $filter;
        }

        if (isset($options[static::FETCH_BY_PATH])) {
            $flags[] = $options[static::FETCH_BY_PATH];
        }

        return $flags;
    }

    /**
     * Given a spec entry from spec list output (p4 streams), 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  Stream                      a (partially) populated instance of this spec class.
     */
    protected static function fromSpecListEntry($listEntry, $flags, ConnectionInterface $connection)
    {
        // move the description into place
        $listEntry['Description'] = $listEntry['desc'];
        unset($listEntry['desc']);

        return parent::fromSpecListEntry($listEntry, $flags, $connection);
    }

    /**
     * This method will ensure the list of streams is in the proper order
     * with children listed after their associated parents.
     *
     * @param   FieldedIterator     $streams    the streams to sort
     * @param   string|null         $parent     the parent id (used for recursion)
     * @return  FieldedIterator     The recursively sorted result
     */
    protected static function sortRecursively($streams, $parent = null)
    {
        // get branches with given parent and sort them
        $children = $streams->filter('Parent', array($parent), FieldedIterator::FILTER_COPY)
                            ->sortBy('Name', array(FieldedIterator::SORT_NATURAL));

        // assemble list and append sorted sub-entries below their parent
        $sorted = new FieldedIterator;
        foreach ($children as $stream) {
            $sorted[] = $stream;
            foreach (static::sortRecursively($streams, $stream->getId()) as $sub) {
                $sorted[] = $sub;
            }
        }

        return $sorted;
    }
}