<?php
/**
 * Parent class for all TestCases.
 *
 * @copyright   2012 Perforce Software. All rights reserved.
 * @license     Please see LICENSE.txt in top-level folder of this distribution.
 * @version     <release>/<patch>
 */

namespace P4Test;

use P4;
use P4\ClientPool\ClientPool;
use P4\Connection\Connection;
use P4\Connection\ConnectionInterface;
use P4\Spec\Protections as P4Protections;
use P4\Spec\User;

class TestCase extends \PHPUnit_Framework_TestCase
{
    const TEST_MAX_TRY_COUNT    = 1000;
    public $p4;
    protected $p4Params         = array();
    protected $noP4dStdErr      = false;

    /**
     * Setup test directories and a functioning perforce server.
     */
    public function setUp()
    {
        // limit the amount of memory any given test can use to 2GB
        ini_set('memory_limit', '4G');

        // get name of the testing class - replace slashes in class
        // name to avoid propagating them into directories' names
        $testClass  = str_replace('\\', '_', get_class($this));
        $testMethod = $this->getName();

        // remove existing directories to start fresh w. each test.
        $this->removeDirectory(DATA_PATH);

        // replace any sketchy characters with - to prevent file creation issues
        $testSuffix  = preg_replace('/[^\w-]/', '-',  $testClass . '-' . $testMethod);

        // create directories needed for testing
        $serverRoot  = DATA_PATH . '/server-'  . $testSuffix;
        $clientRoot  = DATA_PATH . '/clients-' . $testSuffix;
        $directories = array(
            DATA_PATH,
            $serverRoot,
            $clientRoot,
            $clientRoot . '/superuser',
            $clientRoot . '/testuser',
        );
        foreach ($directories as $directory) {
            if (!is_dir($directory)) {
                mkdir($directory, 0777, true);
            }
        }

        // prepare connection params and create p4 connection
        $this->p4Params = array(
            'serverRoot' => $serverRoot,
            'clientRoot' => $clientRoot,
            'port'       => 'rsh:' . P4D_BINARY . ' -i -qr ' . $serverRoot . ' -J off '
                         .  '-vtrack=0 -vserver.locks.dir=disabled',
            'user'       => 'tester',
            'client'     => 'test-client',
            'group'      => 'test-group',
            'password'   => 'testing123'
        );

        // some tests can cause spurious output on stderr on mac
        // optionally wrap the rsh invocation and redirect stderr to /dev/null
        if ($this->noP4dStdErr) {
            $this->p4Params['port'] = 'rsh:bash -c "' . substr($this->p4Params['port'], 4) . ' 2> /dev/null"';
        }

        $this->createP4Connection();

        parent::setUp();
    }

    /**
     * Clean up after ourselves.
     */
    public function tearDown()
    {
        // call p4 library shutdown functions
        if (class_exists('P4\Environment\Environment', false)) {
            P4\Environment\Environment::runShutdownCallbacks();
        }

        // disconnect the p4 connection, if exists
        if (isset($this->p4)) {
            $this->p4->disconnect();
        }

        // clear default connection
        if (class_exists('P4\Connection\Connection', false)) {
            Connection::clearDefaultConnection();
        }

        // clear out shutdown callbacks
        if (class_exists('P4\Environment\Environment', false)) {
            P4\Environment\Environment::setShutdownCallbacks(null);
        }

        // forces collection of any existing garbage cycles
        // so no open file handles prevent files/directories
        // from being removed.
        gc_collect_cycles();

        // remove testing directory
        $this->removeDirectory(DATA_PATH);

        parent::tearDown();

        // if phpunit wants to use a bunch of memory after a test runs (e.g. for code coverage) so be it
        ini_set('memory_limit', -1);
    }

    /**
     * Create a Perforce connection for testing. The perforce connection will
     * connect using a p4d started with the -i (run for inetd) flag.
     *
     * @param   string|null     $type   allow caller to force the API
     *                                  implementation.
     * @return  P4\Connection\ConnectionInterface   a Perforce API implementation
     */
    public function createP4Connection($type = null)
    {
        extract($this->p4Params);

        if (!is_dir($serverRoot)) {
            throw new P4\Exception('Unable to create new server.');
        }

        // create connection.
        $p4 = Connection::factory($port, $user, $client, $password, null, $type);

        // set server into Unicode mode if a charset was set (or set to something other than 'none')
        if (USE_UNICODE_P4D) {
            exec(P4D_BINARY . ' -xi -r ' . $serverRoot, $output, $status);

            if ($status != 0) {
                die("error (" . $status . "): problem setting server into Unicode mode:\n" . $output);
            }
        }

        // add noisy triggers if requested
        if (USE_NOISY_TRIGGERS) {
            $triggers = P4\Spec\Triggers::fetch($this->p4);

            // start with the unique triggers
            $script =  "%quote%" . __DIR__ . "/assets/scripts/noisyTrigger.sh%quote%";
            $lines  = array(
                    "noisy.change-submit    change-submit   //...   \"$script change-submit\"",
                    "noisy.change-content   change-content  //...   \"$script change-content\"",
                    "noisy.change-commit    change-commit   //...   \"$script change-commit\"",
                    "noisy.fix-add          fix-add         fix     \"$script fix-add\"",
                    "noisy.fix-delete       fix-delete      fix     \"$script fix-delete\"",
                    "noisy.shelve-submit    shelve-submit   //...   \"$script shelve-submit\"",
                    "noisy.shelve-commit    shelve-commit   //...   \"$script shelve-commit\"",
                    "noisy.shelve-delete    shelve-delete   //...   \"$script shelve-delete\""
            );

            // put in in/out/save/commit/delete for various form types
            $forms = array(
                'branch', 'change', 'client', 'depot', 'group', 'job', 'label', 'spec',
                'stream', 'triggers', 'typemap', 'user'
            );
            foreach ($forms as $form) {
                $lines[] = "noisy.$form-form-in     form-in     $form   \"$script $form-form-in\"";
                $lines[] = "noisy.$form-form-out    form-out    $form   \"$script $form-form-out\"";
                $lines[] = "noisy.$form-form-save   form-save   $form   \"$script $form-form-save\"";
                $lines[] = "noisy.$form-form-commit form-commit $form   \"$script $form-form-commit\"";
                $lines[] = "noisy.$form-form-delete form-delete $form   \"$script $form-form-delete\"";

            }

            $triggers->setTriggers($lines)->save();

            // force a reconnect as triggers seem to require it
            $triggers->getConnection()->disconnect();
        }

        // give the connection a client manager
        $clients = new ClientPool($p4);
        $clients->setMax(10)->setRoot(DATA_PATH . '/clients')->setPrefix('test-');
        $p4->setService('clients', $clients);

        // create user.
        $userForm = array(
            'User'     => $user,
            'Email'    => $user . '@testhost',
            'FullName' => 'Test User',
            'Password' => $password
        );
        $p4->run('user', '-i', $userForm);
        $p4->run('login', array(), $password);

        // establish protections.
        // This looks like a no-op, but remember that fresh P4 servers consider
        // every user to be a superuser. These operations make only the configured
        // user a superuser, and subsequent users will be 'normal' users.
        $result  = $p4->run('protect', '-o');
        $protect = $result->getData(0);
        $p4->run('protect', '-i', $protect);

        // create client
        $clientForm = array(
            'Client'    => $client,
            'Owner'     => $user,
            'Root'      => $clientRoot . '/superuser',
            'View'      => array('//depot/... //' . $client . '/...')
        );
        $p4->run('client', '-i', $clientForm);

        $this->openPermissions($serverRoot, true);

        $this->p4 = $p4;

        return $this->p4;
    }

    /**
     * Recursively remove a directory and all of it's file contents.
     *
     * @param  string   $directory   The directory to remove.
     * @param  boolean  $recursive   when true, recursively delete directories.
     * @param  boolean  $removeRoot  when true, remove the root (passed) directory too
     */
    public function removeDirectory($directory, $recursive = true, $removeRoot = true)
    {
        if (is_dir($directory)) {
            chmod($directory, 0777);
            $files = new \RecursiveDirectoryIterator($directory);
            foreach ($files as $file) {
                if ($files->isDot()) {
                    continue;
                }
                if ($file->isFile()) {
                    // on Windows, it may take some time for open file handles to
                    // be closed.  We try to unlink a file for TEST_MAX_TRY_COUNT
                    // times and then bail out.
                    $count = 0;
                    chmod($file->getPathname(), 0777);
                    while ($count <= self::TEST_MAX_TRY_COUNT) {
                        try {
                            unlink($file->getPathname());
                            break;
                        } catch (\Exception $e) {
                            $count++;
                            if ($count == self::TEST_MAX_TRY_COUNT) {
                                throw new \Exception(
                                    "Can't delete '" . $file->getPathname() . "' with message ".$e->getMessage()
                                );
                            }
                        }
                    }
                } elseif ($file->isDir() && $recursive) {
                    $this->removeDirectory($file->getPathname(), true, true);
                }
            }

            if ($removeRoot) {
                chmod($directory, 0777);
                $count = 0;
                while ($count <= self::TEST_MAX_TRY_COUNT) {
                    try {
                        rmdir($directory);
                        break;
                    } catch (\Exception $e) {
                        $count++;
                        if ($count == self::TEST_MAX_TRY_COUNT) {
                            throw new \Exception(
                                "Can't delete '" . $directory->getPathname() . "' with message ".$e->getMessage()
                            );
                        }
                    }
                }
            }
        }
    }

    /**
     * Get Perforce config parameters
     *
     * @param  string   $param   Optional - specific Perforce parameter to get
     *
     * @return mixed    A specific Perforce parameter, or all parameters
     */
    public function getP4Params($param = null)
    {
        $params = $this->p4Params;
        if ($param) {
            return isset($params[$param]) ? $params[$param] : null;
        }
        return $params;
    }

    /**
     * Helper method to create and connect as a user with limited access to depot.
     * This will modify protections table by adding lines to grant access for the specified user
     * to only those paths specified. Access defaults to 'list', but a specific mode can be given
     * for each path by specifying the path as the key and the mode as the value.
     *
     * @param   string                  $user       user to create
     * @param   array                   $paths      list of paths to grant user access to each path can be specified as:
     *                                              ['path' => 'permission'] or ['path']
     * @param   ConnectionInterafce     $p4Super    optional - super user connection needed
     *                                              to modify protections table
     * @return  Connection              connection for the new user
     */
    public function connectWithAccess($user, array $paths, ConnectionInterface $p4Super = null)
    {
        $p4Super = $p4Super ?: $this->p4;

        // throw if user already exists
        if (User::exists($user, $this->p4)) {
            throw new \Exception("User already exists.");
        }

        // create user
        $model = new User($this->p4);
        $model->setId($user)
              ->setFullName("$user (limited access)")
              ->setEmail("$user@limited")
              ->save();

        // add paths to the permissions table
        $protectionLines = array();
        foreach ($paths as $path => $permission) {
            if ($path === (int) $path) {
                $path       = $permission;
                $permission = 'list';
            }
            $protectionLines[] = "$permission user $user * $path";
        }

        $protections = P4Protections::fetch($p4Super);
        $protections->setProtections(
            array_merge(
                $protections->getProtections(),
                array("list user $user * -//..."),
                $protectionLines
            )
        )->save();

        // return connection for the new user
        return Connection::factory(
            $this->getP4Params('port'),
            $user,
            'client-' . $user . '-test',
            '',
            null,
            null
        );
    }

    /**
     * Open up permissions (possibly recursively) on a directory. All files
     * in the directory (including the directory itself) will be given a
     * permission mask of 0777. This method checks that the owner of the
     * running PHP process owns each file before it attempts to change
     * permissions on it.
     *
     * @param  string  $directory  the directory to change permissions on.
     * @param  bool    $recursive  optional - whether to do so recursively.
     */
    protected function openPermissions($directory, $recursive = false)
    {
        $uid   = getmyuid();
        $files = new \RecursiveDirectoryIterator($directory);

        foreach ($files as $file) {
            $stat = stat($file->getPathname());
            if ($stat['uid'] != $uid) {
                // skip files we don't own
                continue;
            }
            if (!chmod($file->getPathname(), 0777)) {
                throw new \Exception(
                    "Can't set permissions on '" . $file->getPathname() . "'"
                );
            }
            if ($file->isDir() && $recursive) {
                if ($files->isDot()) {
                    continue;
                }
                $this->openPermissions($file->getPathname(), $recursive);
            }
        }

        chmod($directory, 0777);
    }
}