<?php
/**
 * Abstracts operations against Perforce users.
 *
 * The P4 User class differs from the 'user' spec definition in that it
 * does not have a password field. This is because the password does
 * not behave like other fields. To change a user's password, use the
 * setPassword() function. To test if a given string matches a user's
 * password, use the isPassword() method. It is not possible to get a
 * user's password.
 *
 * @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\Connection\Connection;
use P4\Spec\Exception\Exception;
use P4\Connection\ConnectionInterface;
use P4\Connection\Exception\CommandException;
use P4\Connection\Exception\LoginException;
use P4\Model\Fielded\Iterator as FieldedIterator;

class User extends PluralAbstract
{
    const SPEC_TYPE         = 'user';
    const ID_FIELD          = 'User';

    const OPERATOR_USER     = 'operator';
    const SERVICE_USER      = 'service';
    const STANDARD_USER     = 'standard';

    const FETCH_BY_NAME     = 'name';

    protected $fields       = array(
        'Email'         => array(
            'accessor'  => 'getEmail',
            'mutator'   => 'setEmail'
        ),
        'Update'        => array(
            'accessor'  => 'getUpdateDateTime'
        ),
        'Access'        => array(
            'accessor'  => 'getAccessDateTime'
        ),
        'FullName'      => array(
            'accessor'  => 'getFullName',
            'mutator'   => 'setFullName'
        ),
        'JobView'       => array(
            'accessor'  => 'getJobView',
            'mutator'   => 'setJobView'
        ),
        'Reviews'       => array(
            'accessor'  => 'getReviews',
            'mutator'   => 'setReviews'
        ),
        'Password'      => array(
            'accessor'  => 'getPassword',
            'mutator'   => 'setPassword'
        ),
        'Type'          => array(
            'accessor'  => 'getType',
            'mutator'   => 'setType'
        )
    );

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

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

        return (bool) count($users);
    }

    /**
     * Get all users from Perforce. Adds filtering option.
     *
     * @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 user name pattern (e.g. 'jdo*'),
     *                                                  can be a single string or array of strings.
     *
     * @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)
    {
        $connection = $connection ?: static::getDefaultConnection();

        // if not fetching by name, defer to parent
        if (!isset($options[static::FETCH_BY_NAME])) {
            return parent::fetchAll($options, $connection);
        }

        // get fetch max option and uset it from the options as we handle it manually
        $max = isset($options[static::FETCH_MAXIMUM]) ? $options[static::FETCH_MAXIMUM] : 0;
        unset($options[static::FETCH_MAXIMUM]);

        // sort names before fetching users from the server, so if max is set
        // we get the first max users (according to case-sensitivity of server)
        $names = (array) $options[static::FETCH_BY_NAME];
        if ($connection->isCaseSensitive()) {
            sort($names);
        } else {
            usort($names, 'strcasecmp');
        }

        // fetch users in several (as few as possible) runs as
        // there is a potential to exceed the arg-max on this command
        $users = new FieldedIterator;
        foreach ($connection->batchArgs($names) as $batch) {
            $options[static::FETCH_BY_NAME] = $batch;
            foreach (parent::fetchAll($options, $connection) as $user) {
                $users[$user->getId()] = $user;

                // exit loop if we've reached the max limit
                if ($max && $users->count() == $max) {
                    break(2);
                }
            }
        }

        return $users;
    }

    /**
     * Save this user to Perforce. This will not save changes to the
     * password field. Passwords must be set via setPassword().
     *
     * @return  SpecAbstract    provides a fluent interface
     * @throws  Exception       if no id has been set.
     */
    public function save()
    {
        // ensure all required fields have values.
        $this->validateRequiredFields();

        // set 'password' field to '******' otherwise password
        // will be deleted under certain security levels.
        $values = $this->getRawValues();
        $values['Password'] = '******';

        // initialize command flags with first arg.
        $flags = array("-i");

        // if we are connected with super user privileges, add in the -f flag.
        // otherwise, if not connected as this user, connect as this user.
        $connection = $this->getConnection();
        if ($connection->isSuperUser()) {
            $flags[] = "-f";
        } elseif ($connection->getUser() != $this->getId()) {
            $connection = Connection::factory(
                $connection->getPort(),
                $this->getId()
            );
        }

        // send user spec to server.
        $password = $this->getRawValue('Password');
        try {
            $connection->run(static::SPEC_TYPE, $flags, $values);
        } catch (CommandException $e) {

            // if saving user failed because password has not been
            // set, and the caller supplied a password, try setting
            // the password first, then saving user again.
            //
            // @todo This workaround relies on the fact, that user has been created although previous
            // command had failed. At the moment it seems, that when adding a new user (with non-superuser
            // connection) this error cannot be avoided.
            $errors = $e->getResult()->getErrors();
            if (stristr($errors[0], "password must be set") && is_array($password) && isset($password[0])) {
                $this->changePassword($password[0], null, $connection);
                $connection->run(static::SPEC_TYPE, $flags, $values);
                // avoid redundant password change
                $password[0] = null;
            } else {
                throw $e;
            }
        }

        // change the users password if they have set a new one.
        if (is_array($password) && $password[0] !== null) {
            $this->changePassword($password[0], $password[1], $connection);
        }

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

        return $this;
    }

    /**
     * Remove this user from Perforce.
     *
     * @return  SpecAbstract    provides a fluent interface
     * @throws  Exception       if no id has been set.
     */
    public function delete()
    {
        if ($this->getId() === null) {
            throw new Exception("Cannot delete. No id has been set.");
        }

        // initialize command flags with first arg.
        $flags = array("-d");

        // if we are connected with super user privileges, add in the -f flag.
        // otherwise, if not connected as this user, connect as this user.
        $connection = $this->getConnection();
        if ($connection->isSuperUser()) {
            $flags[] = "-f";
        } elseif ($connection->getUser() != $this->getId()) {
            $connection = Connection::factory(
                $connection->getPort(),
                $this->getId()
            );
        }

        // issue delete user command.
        $flags[] = $this->getId();
        $result = $connection->run(static::SPEC_TYPE, $flags);

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

        return $this;
    }

    /**
     * Get the in-memory password (if one is set).
     *
     * @return  string|null the in-memory password.
     */
    public function getPassword()
    {
        $password = $this->getRawValue('Password');
        return is_array($password) ? $password[0] : null;
    }

    /**
     * Set the user's password to the given password.
     * Does not take effect until save() is called.
     *
     * @param   string|null     $newPassword    the new password string or
     *                                          null to clear in-memory password.
     * @param   string          $oldPassword    optional - existing password.
     * @return  User            provides fluent interface.
     */
    public function setPassword($newPassword, $oldPassword = null)
    {
        $this->setRawValue('Password', array($newPassword, $oldPassword));

        return $this;
    }

    /**
     * Test if the given password is correct for this user.
     *
     * @param   string  $password   the password to test.
     * @return  bool    true if the password is correct, false otherwise.
     */
    public function isPassword($password)
    {
        $p4 = Connection::factory(
            $this->getConnection()->getPort(),
            $this->getId(),
            null,
            $password
        );

        try {
            $p4->login();
            return true;
        } catch (LoginException $e) {
            return false;
        }
    }

    /**
     * Get the type for this account. Expected to be one of 'service', 'operator' or 'standard'.
     *
     * @return  the 'type' of this user, by default 'standard'
     */
    public function getType()
    {
        return $this->hasField('Type') && $this->getRawValue('Type')
            ? $this->getRawValue('Type')
            : static::STANDARD_USER;
    }

    /**
     * Set the user's type.
     *
     * @param   string|null $type   the type, expected to be one of 'service', 'operator' or 'standard'.
     * @return  User        provides fluent interface.
     * @throws  \InvalidArgumentException   if type field doesn't exist and a value other than null/standard is passed
     */
    public function setType($type)
    {
        $type = $type ?: 'standard';
        if (!$this->hasField('Type') && $type != static::STANDARD_USER) {
            throw new \InvalidArgumentException(
                'The user spec lacks a Type field, setting to a value other than null or standard is not supported'
            );
        }

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

    /**
     * Get an Iterator of all the Clients this user owns.
     *
     * @return  FieldedIterator     Iterator of Clients owned by current user
     * @throws  Exception           If no ID is set for this user
     */
    public function getClients()
    {
        if (!static::isValidId($this->getId())) {
            throw new Exception("Cannot get clients. No user id has been set.");
        }

        return Client::fetchAll(
            array(Client::FETCH_BY_OWNER => $this->getId()),
            $this->getConnection()
        );
    }

    /**
     * Get the names of groups that this user belongs to.
     *
     * @return  FieldedIterator     Iterator of Groups this user belongs to.
     */
    public function getGroups()
    {
        if (!static::isValidId($this->getId())) {
            throw new Exception("Cannot get groups. No user id has been set.");
        }

        return Group::fetchAll(
            array(Group::FETCH_BY_USER => $this->getId(), Group::FETCH_INDIRECT),
            $this->getConnection()
        );
    }

    /**
     * Add this user to the named group.
     *
     * @param   string  $group  the name of the group to add the user to.
     * @return  User    provides fluent interface.
     */
    public function addToGroup($group)
    {
        $group = Group::fetch($group, $this->getConnection())
            ->addUser($this->getId())
            ->save();

        return $this;
    }

    /**
     * Get the user's full name.
     *
     * @return  string|null the user's full name.
     */
    public function getFullName()
    {
        return $this->getRawValue('FullName');
    }

    /**
     * Set the user's full name.
     *
     * @param   string|null $name   the full name to give the user.
     * @return  User    provides fluent interface.
     * @throws  \InvalidArgumentException   if given name is not a string.
     */
    public function setFullName($name)
    {
        if ($name !== null && !is_string($name)) {
            throw new \InvalidArgumentException("Cannot set full name. Invalid type given.");
        }
        return $this->setRawValue('FullName', $name);
    }

    /**
     * Get the user's email address.
     *
     * @return  string|null the user's email address.
     */
    public function getEmail()
    {
        return $this->getRawValue('Email');
    }

    /**
     * Set the user's email address. We don't require a valid email
     * address here because Perforce doesn't enforce one. If we did
     * then users with invalid emails would be innaccessible.
     *
     * @param   string|null $email  the email of the user.
     * @return  User    provides fluent interface.
     * @throws  \InvalidArgumentException   if given email is not a string.
     */
    public function setEmail($email)
    {
        if ($email !== null && !is_string($email)) {
            throw new \InvalidArgumentException("Cannot set email. Invalid type given.");
        }
        return $this->setRawValue("Email", $email);
    }

    /**
     * Get the user's job view (selects jobs for inclusion during changelist creation).
     *
     * @return  string|null the user's job view.
     */
    public function getJobView()
    {
        return $this->getRawValue('JobView');
    }

    /**
     * Set the user's job view (selects jobs for inclusion during changelist creation).
     *
     * @param   string|null $jobView    the user's job view.
     * @return  User    provides fluent interface.
     * @throws  \InvalidArgumentException   if given job view is not a string.
     */
    public function setJobView($jobView)
    {
        if ($jobView !== null && !is_string($jobView)) {
            throw new \InvalidArgumentException("Cannot set job view. Invalid type given.");
        }
        return $this->setRawValue("JobView", $jobView);
    }


    /**
     * Get the reviews for this client (depot paths to notify user of changes to).
     *
     * @return  array   list of filespec strings.
     */
    public function getReviews()
    {
        return $this->getRawValue('Reviews') ?: array();
    }

    /**
     * Set the reviews for this user (depot paths to notify user of changes to).
     * Reviews is passed as an array of filespec strings.
     *
     * @param   array   $reviews    Review entries - an array of filespec strings.
     * @return  User    provides a fluent interface.
     * @throws  \InvalidArgumentException   if reviews is not an array.
     */
    public function setReviews($reviews)
    {
        if (!is_array($reviews)) {
            throw new \InvalidArgumentException('Reviews must be passed as array.');
        }

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

    /**
     * Get the last update time for this user 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 user 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');
    }

    /**
     * Check if automatic user creation is enabled.
     *
     * @param   ConnectionInterface         $connection     optional - a specific connection to use.
     * @return  bool                        true if auto user creation is enabled, false otherwise.
     * @throws  Exception                   if we exceed the maximum number of unlikely usernames
     */
    public static function isAutoUserCreationEnabled(ConnectionInterface $connection = null)
    {
        // if no connection given, use default.
        $connection = $connection ?: static::getDefaultConnection();

        $port = $connection->getPort();

        // limit the number of 'unlikely' username lookups to 3.
        $maxLookups = 3;
        for ($i = 0; $i < $maxLookups; $i++) {
            // generate an unlikely user name.
            $username = md5(mt_rand());

            // try to run p4 users as the unlikely user
            // (perforce won't create an account for this lookup).
            try {
                $connection = Connection::factory($port, $username);
                $result = $connection->run('users', $username);
            } catch (CommandException $e) {
                return false;
            }

            // ensure unlikely user doesn't exist.
            if (!$result->getData()) {
                return true;
            }
        }

        throw new \Exception(
            "Failed to determine if auto user creation is enabled."
            . "Exceeded the maximum of $maxLookups 'unlikely' username lookups."
        );
    }

    /**
     * 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);

        // ensure we include service/operator users
        $flags[] = '-a';

        if (isset($options[static::FETCH_BY_NAME])) {
            $name = $options[static::FETCH_BY_NAME];

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

            // if array is given, ensure values are non-empty strings
            if (is_array($name)) {
                $names    = $name;
                $filtered = array_filter($names, 'is_string');
                $filtered = array_filter($filtered, 'trim');

                if (count($names) !== count($filtered)) {
                    throw new \InvalidArgumentException(
                        'Filter by Name expects all names in the input array to be non-empty strings'
                    );
                }
                $flags = array_merge($flags, $names);
            } else {
                $flags[] = $name;
            }
        }

        return $flags;
    }

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

    /**
     * Immediately set the user's password to the given password.
     * If the current password is given, it will be validated.
     *
     * @param   string                      $newPassword    the new password.
     * @param   string                      $oldPassword    optional - existing password.
     * @param   ConnectionInterface         $connection     optional - a specific connection to use.
     * @return  User                        provides fluent interface.
     * @throws  Exception                   if the password can't be set.
     */
    protected function changePassword(
        $newPassword,
        $oldPassword = null,
        ConnectionInterface $connection = null
    ) {
        $input = array();

        // if caller supplied an old password, prepend it to input array.
        if ($oldPassword) {
            $input[] = $oldPassword;
        }

        // always confirm old password
        $input[] = $newPassword;
        $input[] = $newPassword;

        // if no connection given, use default.
        $connection = $connection ?: $this->getConnection();

        // if not connected as this user, supply user id.
        $flags = array();
        if ($connection->getUser() !== $this->getId()) {
            $flags[] = $this->getId();
        }

        // attempt to set password.
        $result = $connection->run("password", $flags, $input);

        // change connection credentials if password for connected user has been changed
        // if we don't do this automatically, subsequent commands will fail when using
        // the command-line connection, but would succeed using the P4PHP extension.
        if ($connection->getUser() === $this->getId()) {
            $connection->setPassword($newPassword);
            if ($connection->getTicket()) {
                $connection->login($connection->isTicketUnlocked());
            }
        }

        return $this;
    }
}