<?php
/**
 * Abstracts operations against Perforce clients/workspaces.
 *
 * @copyright   2011 Perforce Software. All rights reserved.
 * @license     Please see LICENSE.txt in top-level folder of this distribution.
 * @version     <release>/<patch>
 * @todo        Add support for 'p4 opened'
 * @todo        Deal with converting updated/created date/times from listing format
 * @todo        Return DateTime objects as appropriate
 */

namespace P4\Spec;

use P4\Validate;
use P4\Connection\ConnectionInterface;

class Client extends PluralAbstract
{
    const SPEC_TYPE         = 'client';
    const ID_FIELD          = 'Client';

    const FETCH_BY_NAME     = 'name';
    const FETCH_BY_OWNER    = 'owner';
    const FETCH_BY_STREAM   = 'stream';

    protected $fields       = array(
        'Update'        => array(
            'accessor'  => 'getUpdateDateTime'
        ),
        'Access'        => array(
            'accessor'  => 'getAccessDateTime'
        ),
        'Owner'         => array(
            'accessor'  => 'getOwner',
            'mutator'   => 'setOwner'
        ),
        'Host'          => array(
            'accessor'  => 'getHost',
            'mutator'   => 'setHost'
        ),
        'Description'   => array(
            'accessor'  => 'getDescription',
            'mutator'   => 'setDescription'
        ),
        'Root'          => array(
            'accessor'  => 'getRoot',
            'mutator'   => 'setRoot'
        ),
        'Options'       => array(
            'accessor'  => 'getOptions',
            'mutator'   => 'setOptions'
        ),
        'SubmitOptions' => array(
            'accessor'  => 'getSubmitOptions',
            'mutator'   => 'setSubmitOptions'
        ),
        'LineEnd'       => array(
            'accessor'  => 'getLineEnd',
            'mutator'   => 'setLineEnd'
        ),
        'View'          => array(
            'accessor'  => 'getView',
            'mutator'   => 'setView'
        ),
        'Stream'        => array(
            'accessor'  => 'getStream',
            'mutator'   => 'setStream'
        )
    );

    /**
     * Get all Clients 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_NAME - set to client name pattern (e.g. 'pc*').
     *  FETCH_BY_OWNER - set to owner's username (e.g. 'jdoe').
     * FETCH_BY_STREAM - set to stream name (e.g. '//depotname/string').
     *
     * @param   ConnectionInterface     $connection  optional - a specific connection to use.
     * @return  \P4\Model\Fielded\Iterator  all records of this type.
     */
    public static function fetchAll($options = array(), ConnectionInterface $connection = null)
    {
        // simply return parent - method exists to document options.
        return parent::fetchAll($options, $connection);
    }

    /**
     * Save this spec to Perforce.
     * Extends parent to blank out the 'View' if a stream is specified. You cannot
     * edit the view on a stream spec but leaving it can cause errors.
     *
     * @return  SpecAbstract        provides a fluent interface
     */
    public function save()
    {
        if ($this->get('Stream')) {
            $this->set('View', array());
        }

        return parent::save();
    }

    /**
     * Remove this client. Extends parent to offer force delete.
     *
     * @param   boolean     $force      pass true to force delete this client.
     * @return  Client      provides fluent interface.
     */
    public function delete($force = false)
    {
        return parent::delete($force ? array('-f') : null);
    }

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

        $clients = static::fetchAll(
            array(
                static::FETCH_BY_NAME => $id,
                static::FETCH_MAXIMUM => 1
            ),
            $connection
        );

        return (bool) count($clients);
    }

    /**
     * Extends the parent temp cleanup callback to try reverting any
     * files prior to client deletion. This won't always be successful,
     * but it will reduce the number of temp clients that cannot be
     * deleted due to open files.
     *
     * @return callable     A callback function with the signature function($entry)
     */
    protected static function getTempCleanupCallback()
    {
        $parentCallback = parent::getTempCleanupCallback();

        return function ($entry) use ($parentCallback) {
            $p4       = $entry->getConnection();
            $original = $p4->getClient();
            $p4->setClient($entry->getId());

            // try to revert any open files - if this fails we
            // want to carry on so the original client gets restored
            // and we still attempt to delete the client spec.
            try {
                $p4->run('revert', array('-k', '//...'));
            } catch (\Exception $e) {
                // carry on!
            }

            // restore the original client
            $p4->setClient($original);

            // let parent delete the spec entry.
            return $parentCallback($entry);
        };
    }

    /**
     * Get the last update time for this client spec.
     * This value is read only, no setUpdateTime 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 update, formatted "2009/11/23 12:57:06" or null
     */
    public function getUpdateDateTime()
    {
        return $this->getRawValue('Update');
    }

    /**
     * Get the last access time for this client spec.
     * This value is read only, no setAccessTime 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 client.
     *
     * @return  string|null User who owns this record.
     */
    public function getOwner()
    {
        return $this->getRawValue('Owner');
    }

    /**
     * Set the owner of this client to passed value.
     *
     * @param   string|null $owner  A string containing username
     * @return  Client      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 host setting for this client.
     *
     * @return  string|null Host name set for this client, empty string for any.
     */
    public function getHost()
    {
        return $this->getRawValue('Host');
    }

    /**
     * If set, restricts access to the named host. Specify a blank string or null
     * to allow access from all hosts.
     *
     * @param   string|null $host   Host name for this client, empty string or null for any
     * @return  Client      provides a fluent interface.
     * @throws  \InvalidArgumentException Host is incorrect type.
     */
    public function setHost($host)
    {
        if (!is_string($host) && !is_null($host)) {
            throw new \InvalidArgumentException('Host must be a string or null.');
        }

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

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

    /**
     * Set a description for this client.
     *
     * @param   string|null $description    description for this client.
     * @return  Client      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 the base directory of the client workspace.
     *
     * @return  string|null Base directory of the client workspace.
     */
    public function getRoot()
    {
        return $this->getRawValue('Root');
    }

    /**
     * Set the base directory of the client workspace.
     *
     * @param   string|null $root   Base directory for the client workspace.
     * @return  Client      provides a fluent interface.
     * @throws  \InvalidArgumentException Root is incorrect type.
     */
    public function setRoot($root)
    {
        if (!is_string($root) && !is_null($root)) {
            throw new \InvalidArgumentException('Root must be a string or null.');
        }

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

    /**
     * Get options for this client.
     * Returned array will contain one option per element e.g.:
     * array (
     *     0 => 'noallwrite',
     *     1 => 'noclobber',
     *     2 => 'nocompress',
     *     3 => 'unlocked',
     *     4 => 'nomodtime',
     *     5 => 'rmdir'
     * )
     *
     * @return  array  options which are set on this client.
     */
    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 client.
     * 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 client in array or string.
     * @return  Client        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 submit options for this client.
     * Returned array will contain one option per element e.g.:
     * array (
     *     0 => 'submitunchanged'
     * )
     *
     * @return  array  submit options which are set on this client.
     */
    public function getSubmitOptions()
    {
        $options = $this->getRawValue('SubmitOptions');
        $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 submit options for this client.
     * Accepts an array, format detailed in getSubmitOptions, or a single string
     * containing a space seperated list of options.
     *
     * @param   array|string  $options  submit options to set on this client in array or string
     * @return  Client        provides a fluent interface.
     * @throws  \InvalidArgumentException Submit Options are incorrect type.
     */
    public function setSubmitOptions($options)
    {
        if (is_array($options)) {
            $options = implode(' ', $options);
        }

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

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

    /**
     * Get the line ending setting for this client.
     * Will be one of: local/unix/mac/win/share
     *
     * @return  string|null Line ending setting for this client.
     */
    public function getLineEnd()
    {
        return $this->getRawValue('LineEnd');
    }

    /**
     * Set the line ending setting for this client.
     * See getLineEnd for available options.
     *
     * @param   string|null $lineEnd    Line ending setting for this client.
     * @return  Client      provides a fluent interface.
     * @throws  \InvalidArgumentException lineEnd is incorrect type.
     */
    public function setLineEnd($lineEnd)
    {
        if (!is_string($lineEnd) && !is_null($lineEnd)) {
            throw new \InvalidArgumentException('Line End must be a string or null.');
        }

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

    /**
     * Get the view for this client.
     * View entries will be returned as an array with 'depot' and 'client' entries, e.g.:
     * array (
     *      0 => array (
     *          'depot'  => '//depot/example/with space/...',
     *          'client' => '//client.name/...'
     *      )
     *  )
     *
     * @return  array  list view entries for this client.
     */
    public function getView()
    {
        // The raw view data is formatted as:
        //  array (
        //      0 => '"//depot/example/with space/..." //client.name/...',
        //  )
        //
        // We split this into 'depot' and 'client' components via the str_getcsv function
        // and key the two resulting entries as 'depot' and 'client'
        $view = array();
        // The ?: translates empty views into an empty array
        foreach ($this->getRawValue('View') ?: array() as $entry) {
            $entry = str_getcsv($entry, ' ');
            $view[] = array_combine(array('depot','client'), $entry);
        }

        return $view;
    }

    /**
     * Set the view for this client.
     * View is passed as an array of view entries. Each view entry can be an array with
     * 'depot' and 'client' entries or a raw string.
     *
     * @param   array  $view  View entries, formatted into depot/client sub-arrays.
     * @return  Client        provides a fluent interface.
     * @throws  \InvalidArgumentException View array, or a view entry, is incorrect type.
     */
    public function setView($view)
    {
        if (!is_array($view)) {
            throw new \InvalidArgumentException('View must be passed as array.');
        }

        // The View array contains either:
        // - Child arrays keyed on depot/client which we glue together
        // - Raw strings which we simply leave as is
        // The below foreach run will normalize the whole thing for storage
        $parsedView = array();
        foreach ($view as $entry) {
            if (is_array($entry) &&
                isset($entry['depot'], $entry['client']) &&
                is_string($entry['depot']) &&
                is_string($entry['client'])) {
                $entry = '"'. $entry['depot'] .'" "'. $entry['client'] .'"';
            }

            if (!is_string($entry)) {
                throw new \InvalidArgumentException(
                    "Each view entry must be a 'depot' and 'client' array or a string."
                );
            }

            $validate = str_getcsv($entry, ' ');
            if (count($validate) != 2 || trim($validate[0]) === '' || trim($validate[1]) === '') {
                throw new \InvalidArgumentException(
                    "Each view entry must contain two paths, no more, no less."
                );
            }

            $parsedView[] = $entry;
        };

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

    /**
     * Add a view mapping to this client.
     *
     * @param   string  $depot      the depot half of the view mapping.
     * @param   string  $client     the client half of the view mapping.
     * @return  Client      provides a fluent interface.
     */
    public function addView($depot, $client)
    {
        $mappings   = $this->getView();
        $mappings[] = array("depot" => $depot, "client" => $client);

        return $this->setView($mappings);
    }

    /**
     * Updates the 'client' half of the view to ensure the
     * current client ID is used.
     *
     * @return Client       provides a fluent interface.
     */
    public function touchUpView()
    {
        $view = $this->getView();
        foreach ($view as &$mapping) {
            $mapping['client'] = preg_replace(
                "#//[^/]*/#",
                '//' . $this->getId() . '/',
                $mapping['client']
            );
        }
        $this->setView($view);

        return $this;
    }

    /**
     * Get the stream this client is dedicated to.
     *
     * @return  string|null     Stream setting for this client.
     */
    public function getStream()
    {
        return $this->getRawValue('Stream');
    }

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

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

    /**
     * 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\SpecName;
        $validator->allowRelative(true);
        $validator->allowPercent(false);
        $validator->allowCommas(false);
        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_NAME])) {
            $name = $options[static::FETCH_BY_NAME];

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

            $flags[] = '-e';
            $flags[] = $name;
        }

        if (isset($options[static::FETCH_BY_OWNER])) {
            $owner = $options[static::FETCH_BY_OWNER];

            // We allow empty values as this returns clients with no owner
            if (!is_string($owner) || trim($owner) === '') {
                throw new \InvalidArgumentException(
                    'Filter by Owner expects a non-empty string as input'
                );
            }

            $flags[] = '-u';
            $flags[] = $owner;
        }

        if (isset($options[static::FETCH_BY_STREAM])) {
            $stream = $options[static::FETCH_BY_STREAM];

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

            $flags[] = '-S';
            $flags[] = $stream;
        }

        return $flags;
    }
}