<?php
/**
 * Omeka
 * 
 * @copyright Copyright 2007-2012 Roy Rosenzweig Center for History and New Media
 * @license http://www.gnu.org/licenses/gpl-3.0.txt GNU GPLv3
 */

/**
 * Abstract test case class that bootstraps the entire application.
 * 
 * @package Omeka\Test
 */
abstract class Omeka_Test_AppTestCase extends Omeka_Test_TestCase
{

    /**
     * Flag that determines whether the test should run against admin or public.  
     * Defaults to true (for admin). 
     *
     * @var bool
     */
    protected $_isAdminTest = true;

    /**
     * @var Omeka_Application
     */
    public $application;

    /* Start of code ported from Zend_Test_PHPUnit_ControllerTestCase */

    /**
     * @var mixed Bootstrap file path or callback
     */
    public $bootstrap;

    /**
     * @var Zend_Controller_Front
     */
    protected $_frontController;

    /**
     * @var Zend_Dom_Query
     */
    protected $_query;

    /**
     * @var Zend_Controller_Request_Abstract
     */
    protected $_request;

    /**
     * @var Zend_Controller_Response_Abstract
     */
    protected $_response;

    /**
     * XPath namespaces
     * @var array
     */
    protected $_xpathNamespaces = [];

    /**
     * Overloading: prevent overloading to special properties
     *
     * @param  string $name
     * @param  mixed  $value
     * @throws Zend_Exception
     */
    public function __set($name, $value)
    {
        if (in_array($name, ['request', 'response', 'frontController'])) {
            require_once 'Zend/Exception.php';
            throw new Zend_Exception(sprintf('Setting %s object manually is not allowed', $name));
        }
        $this->$name = $value;
    }

    /**
     * Bootstrap the front controller
     *
     * Resets the front controller, and then bootstraps it.
     *
     * If {@link $bootstrap} is a callback, executes it; if it is a file, it include's
     * it. When done, sets the test case request and response objects into the
     * front controller.
     */
    final public function bootstrap()
    {
        $this->reset();
        if (null !== $this->bootstrap) {
            if ($this->bootstrap instanceof Zend_Application) {
                $this->bootstrap->bootstrap();
                $this->_frontController = $this->bootstrap->getBootstrap()->getResource('frontcontroller');
            } elseif (is_callable($this->bootstrap)) {
                call_user_func($this->bootstrap);
            } elseif (is_string($this->bootstrap)) {
                require_once 'Zend/Loader.php';
                if (Zend_Loader::isReadable($this->bootstrap)) {
                    include $this->bootstrap;
                }
            }
        }
        $this->frontController
             ->setRequest($this->getRequest())
             ->setResponse($this->getResponse());
    }

    /**
     * Dispatch the MVC
     *
     * If a URL is provided, sets it as the request URI in the request object.
     * Then sets test case request and response objects in front controller,
     * disables throwing exceptions, and disables returning the response.
     * Finally, dispatches the front controller.
     
     * @internal Overrides the parent behavior to enable automatic throwing of
     * exceptions from dispatching.
     *
     * @param string|null $url
     * @param bool $throwExceptions
     */
    public function dispatch($url = null, $throwExceptions = true)
    {
        // redirector should not exit
        $redirector = Zend_Controller_Action_HelperBroker::getStaticHelper('redirector');
        $redirector->setExit(false);

        // json helper should not exit
        $json = Zend_Controller_Action_HelperBroker::getStaticHelper('json');
        $json->suppressExit = true;

        $request    = $this->getRequest();
        if (null !== $url) {
            $request->setRequestUri($url);
        }
        $request->setPathInfo(null);

        $controller = $this->getFrontController();
        $this->frontController
             ->setRequest($request)
             ->setResponse($this->getResponse())
             ->throwExceptions(false)
             ->returnResponse(false);

        if ($this->bootstrap instanceof Zend_Application) {
            $this->bootstrap->run();
        } else {
            $this->frontController->dispatch();
        }

        if ($throwExceptions) {
            if (isset($this->request->error_handler)) {
                throw $this->request->error_handler->exception;
            }
        }
    }

    /**
     * Reset MVC state
     *
     * Creates new request/response objects, resets the front controller
     * instance, and resets the action helper broker.
     *
     * @todo   Need to update Zend_Layout to add a resetInstance() method
     */
    public function reset()
    {
        $_SESSION = [];
        $_GET     = [];
        $_POST    = [];
        $_COOKIE  = [];
        $this->resetRequest();
        $this->resetResponse();
        Zend_Layout::resetMvcInstance();
        Zend_Controller_Action_HelperBroker::resetHelpers();
        $this->frontController->resetInstance();
        Zend_Session::$_unitTestEnabled = true;
        Omeka_View::$customScriptsLoaded = false;
    }

    /**
     * Rest all view placeholders
     */
    protected function _resetPlaceholders()
    {
        $registry = Zend_Registry::getInstance();
        $remove   = [];
        foreach ($registry as $key => $value) {
            if (strstr($key, '_View_')) {
                $remove[] = $key;
            }
        }

        foreach ($remove as $key) {
            unset($registry[$key]);
        }
    }

    /**
     * Reset the request object
     *
     * Useful for test cases that need to test multiple trips to the server.
     *
     * @return Zend_Test_PHPUnit_ControllerTestCase
     */
    public function resetRequest()
    {
        if ($this->_request instanceof Zend_Controller_Request_HttpTestCase) {
            $this->_request->clearQuery()
                           ->clearPost();
        }
        $this->_request = null;
        return $this;
    }

    /**
     * Reset the response object
     *
     * Useful for test cases that need to test multiple trips to the server.
     *
     * @return Zend_Test_PHPUnit_ControllerTestCase
     */
    public function resetResponse()
    {
        $this->_response = null;
        $this->_resetPlaceholders();
        return $this;
    }

    /**
     * Register XPath namespaces
     *
     * @param array $xpathNamespaces
     */
    public function registerXpathNamespaces($xpathNamespaces)
    {
        $this->_xpathNamespaces = $xpathNamespaces;
    }

    /**
     * Assert that the last handled request used the given module
     *
     * @param string $module
     * @param string $message
     */
    public function assertModule($module, $message = '')
    {
        $this->_incrementAssertionCount();
        if ($module != $this->request->getModuleName()) {
            $msg = sprintf('Failed asserting last module used <"%s"> was "%s"',
                $this->request->getModuleName(),
                $module
            );
            if (!empty($message)) {
                $msg = $message . "\n" . $msg;
            }
            $this->fail($msg);
        }
    }

    /**
     * Assert that the last handled request did NOT use the given module
     *
     * @param string $module
     * @param string $message
     */
    public function assertNotModule($module, $message = '')
    {
        $this->_incrementAssertionCount();
        if ($module == $this->request->getModuleName()) {
            $msg = sprintf('Failed asserting last module used was NOT "%s"', $module);
            if (!empty($message)) {
                $msg = $message . "\n" . $msg;
            }
            $this->fail($msg);
        }
    }

    /**
     * Assert that the last handled request used the given controller
     *
     * @param string $controller
     * @param string $message
     */
    public function assertController($controller, $message = '')
    {
        $this->_incrementAssertionCount();
        if ($controller != $this->request->getControllerName()) {
            $msg = sprintf('Failed asserting last controller used <"%s"> was "%s"',
                $this->request->getControllerName(),
                $controller
            );
            if (!empty($message)) {
                $msg = $message . "\n" . $msg;
            }
            $this->fail($msg);
        }
    }

    /**
     * Assert that the last handled request did NOT use the given controller
     *
     * @param  string $controller
     * @param  string $message
     */
    public function assertNotController($controller, $message = '')
    {
        $this->_incrementAssertionCount();
        if ($controller == $this->request->getControllerName()) {
            $msg = sprintf('Failed asserting last controller used <"%s"> was NOT "%s"',
                $this->request->getControllerName(),
                $controller
            );
            if (!empty($message)) {
                $msg = $message . "\n" . $msg;
            }
            $this->fail($msg);
        }
    }

    /**
     * Assert that the last handled request used the given action
     *
     * @param string $action
     * @param string $message
     */
    public function assertAction($action, $message = '')
    {
        $this->_incrementAssertionCount();
        if ($action != $this->request->getActionName()) {
            $msg = sprintf('Failed asserting last action used <"%s"> was "%s"', $this->request->getActionName(), $action);
            if (!empty($message)) {
                $msg = $message . "\n" . $msg;
            }
            $this->fail($msg);
        }
    }

    /**
     * Assert that the last handled request did NOT use the given action
     *
     * @param string $action
     * @param string $message
     */
    public function assertNotAction($action, $message = '')
    {
        $this->_incrementAssertionCount();
        if ($action == $this->request->getActionName()) {
            $msg = sprintf('Failed asserting last action used <"%s"> was NOT "%s"', $this->request->getActionName(), $action);
            if (!empty($message)) {
                $msg = $message . "\n" . $msg;
            }
            $this->fail($msg);
        }
    }

    /**
     * Assert that the specified route was used
     *
     * @param string $route
     * @param string $message
     */
    public function assertRoute($route, $message = '')
    {
        $this->_incrementAssertionCount();
        $router = $this->frontController->getRouter();
        if ($route != $router->getCurrentRouteName()) {
            $msg = sprintf('Failed asserting matched route was "%s", actual route is %s',
                $route,
                $router->getCurrentRouteName()
            );
            if (!empty($message)) {
                $msg = $message . "\n" . $msg;
            }
            $this->fail($msg);
        }
    }

    /**
     * Assert that the route matched is NOT as specified
     *
     * @param string $route
     * @param string $message
     */
    public function assertNotRoute($route, $message = '')
    {
        $this->_incrementAssertionCount();
        $router = $this->frontController->getRouter();
        if ($route == $router->getCurrentRouteName()) {
            $msg = sprintf('Failed asserting route matched was NOT "%s"', $route);
            if (!empty($message)) {
                $msg = $message . "\n" . $msg;
            }
            $this->fail($msg);
        }
    }

    /**
     * Retrieve front controller instance
     *
     * @return Zend_Controller_Front
     */
    public function getFrontController()
    {
        if (null === $this->_frontController) {
            $this->_frontController = Zend_Controller_Front::getInstance();
        }
        return $this->_frontController;
    }

    /**
     * Retrieve test case request object
     *
     * @return Zend_Controller_Request_HttpTestCase
     */
    public function getRequest()
    {
        if (null === $this->_request) {
            require_once 'Zend/Controller/Request/HttpTestCase.php';
            $this->_request = new Zend_Controller_Request_HttpTestCase;
        }
        return $this->_request;
    }

    /**
     * Retrieve test case response object
     *
     * @return Zend_Controller_Response_HttpTestCase
     */
    public function getResponse()
    {
        if (null === $this->_response) {
            require_once 'Zend/Controller/Response/HttpTestCase.php';
            $this->_response = new Zend_Controller_Response_HttpTestCase;
        }
        return $this->_response;
    }

    /**
     * Retrieve DOM query object
     *
     * @return Zend_Dom_Query
     */
    public function getQuery()
    {
        if (null === $this->_query) {
            require_once 'Zend/Dom/Query.php';
            $this->_query = new Zend_Dom_Query;
        }
        return $this->_query;
    }

    /**
     * URL Helper
     *
     * @param  array  $urlOptions
     * @param  string $name
     * @param  bool   $reset
     * @param  bool   $encode
     * @throws Exception
     * @throws Zend_Controller_Router_Exception
     * @return string
     */
    public function url($urlOptions = [], $name = null, $reset = false, $encode = true)
    {
        $frontController = $this->getFrontController();
        $router = $frontController->getRouter();
        if (!$router instanceof Zend_Controller_Router_Rewrite) {
            throw new Exception('This url helper utility function only works when the router is of type Zend_Controller_Router_Rewrite');
        }
        if (count($router->getRoutes()) == 0) {
            $router->addDefaultRoutes();
        }
        return $router->assemble($urlOptions, $name, $reset, $encode);
    }

    /**
     * Urlize options
     *
     * @param  array $urlOptions
     * @param  bool  $actionControllerModuleOnly
     * @return mixed
     */
    public function urlizeOptions($urlOptions, $actionControllerModuleOnly = true)
    {
        $ccToDash = new Zend_Filter_Word_CamelCaseToDash();
        foreach ($urlOptions as $n => $v) {
            if (in_array($n, ['action', 'controller', 'module'])) {
                $urlOptions[$n] = $ccToDash->filter($v);
            }
        }
        return $urlOptions;
    }

    /**
     * Increment assertion count
     */
    protected function _incrementAssertionCount()
    {
        $stack = debug_backtrace();
        foreach ($stack as $step) {
            if (isset($step['object'])
                && $step['object'] instanceof \PHPUnit\Framework\TestCase
            ) {
                $step['object']->addToAssertionCount(1);
                break;
            }
        }
    }

    /* End of code ported from Zend_Test_PHPUnit_ControllerTestCase */

    /**
     * Bootstrap the application on each test run.
     */
    public function setUpLegacy()
    {
        $this->bootstrap = [$this, 'appBootstrap'];
        $this->bootstrap();
    }

    /**
     * Proxy gets to properties to allow access to bootstrap container
     * properties.
     *
     * @param string $property
     * @return mixed
     */
    public function __get($property)
    {
        switch ($property) {
            case 'request':
                return $this->getRequest();
            case 'response':
                return $this->getResponse();
            case 'frontController':
                return $this->getFrontController();
        }

        if (!isset($this->application)) {
            return;
        }
        return $this->application->getBootstrap()->getResource($property);
    }

    /**
     * Bootstrap the application.
     */
    public function appBootstrap()
    {
        $this->application = new Omeka_Application('testing', [
            'config' => CONFIG_DIR . '/' . 'application.ini']);

        // No idea why we actually need to add the default routes.
        $this->frontController->getRouter()->addDefaultRoutes();
        $this->frontController->setParam('bootstrap', $this->application->getBootstrap());
        $this->getRequest()->setBaseUrl('');

        if ($this->_isAdminTest) {
            $this->_setUpThemeBootstrap('admin');
        } else {
            $this->_setUpThemeBootstrap('public');
        }

        $this->setUpBootstrap($this->application->getBootstrap());
        $this->application->bootstrap();
    }

    /**
     * Subclasses can override this to perform specialized setup on the Omeka
     * application.
     *
     * @param Zend_Application_Bootstrap $bootstrap
     */
    public function setUpBootstrap($bootstrap)
    {
    }

    /**
     * Reset objects that carry global state between test runs.
     */
    public function tearDownLegacy()
    {
        // This fixes a "too many open files" error caused by hanging references
        // to the logger object somewhere in the code (could be anywhere).
        // Since log files are only closed in log writer shutdown (only in
        // destructor), this hanging reference keeps another file open with each
        // test run.
        if ($this->logger instanceof Zend_Log) {
            $this->logger->__destruct();
        }
        if ($this->db instanceof Omeka_Db) {
            Omeka_Test_Resource_Db::setDbAdapter($this->db->getAdapter());
            $this->db->rollBack();
        }
        Zend_Registry::_unsetInstance();

        unset($this->bootstrap);
        unset($this->application);
    }

    /**
     * Trick the environment into thinking that a user has been authenticated.
     *
     * @param User $user
     */
    protected function _authenticateUser(User $user)
    {
        if (!$user->exists()) {
            throw new InvalidArgumentException("User is not persistent in db.");
        }
        $bs = $this->application->getBootstrap();
        $bs->auth->getStorage()->write($user->id);
        $bs->currentUser = $user;
        $bs->getContainer()->currentuser = $user;
        $aclHelper = Zend_Controller_Action_HelperBroker::getStaticHelper('Acl');
        $aclHelper->setCurrentUser($user);
    }

    /**
     * Get the user that is installed by default.
     *
     * @return User
     */
    protected function _getDefaultUser()
    {
        return $this->db->getTable('User')->find(Omeka_Test_Resource_Db::DEFAULT_USER_ID);
    }

    /**
     * Set up the bootstrap differently depending on whether the test is meant
     * for the public or admin themes.
     */
    private function _setUpThemeBootstrap($themeType)
    {
        switch ($themeType) {
            case 'admin':
                $this->frontController->setParam('admin', true);
                $this->frontController->registerPlugin(new Omeka_Controller_Plugin_Admin);
                $this->application->getBootstrap()->setOptions([
                    'resources' => [
                        'theme' => [
                            'basePath' => ADMIN_THEME_DIR,
                            'webBasePath' => '/admin/themes/'
                        ]
                    ]
                ]);
                break;
            case 'public':
                $this->application->getBootstrap()->setOptions([
                    'resources' => [
                        'theme' => [
                            'basePath' => PUBLIC_THEME_DIR,
                            'webBasePath' => '/themes/'
                        ]
                    ]
                ]);
                break;
            default:
                throw new InvalidArgumentException("Invalid theme type given.");
                break;
        }
    }

    /**
     * Assert against DOM selection
     *
     * @param string $path CSS selector path
     * @param string $message
     */
    public function assertQuery($path, $message = '')
    {
        $content    = $this->response->outputBody();
        $domQuery = new Zend_Dom_Query($content);
        $result = $domQuery->query($path);
        $this->assertNotCount(0, $result, $message);
    }

    /**
     * Assert against DOM selection
     *
     * @param string $path CSS selector path
     * @param string $message
     */
    public function assertNotQuery($path, $message = '')
    {
        $content    = $this->response->outputBody();
        $domQuery = new Zend_Dom_Query($content);
        $result = $domQuery->query($path);
        $this->assertCount(0, $result, $message);
    }

    /**
     * Assert against DOM selection; node should contain content
     *
     * @param string $path CSS selector path
     * @param string $match content that should be contained in matched nodes
     * @param string $message
     */
    public function assertQueryContentContains($path, $match, $message = '')
    {
        $content    = $this->response->outputBody();
        $domQuery = new Zend_Dom_Query($content);
        $result = $domQuery->query($path);
        $this->assertNotCount(0, $result, $message);
        $contains = false;
        foreach ($result as $node) {
            $nodeContent = $this->_getNodeContent($node);
            if (strstr($nodeContent, $match)) {
                $contains = true;
                break;
            }
        }
        $this->assertTrue($contains, $message);
    }

    /**
     * Assert against XPath selection
     *
     * @param string $path XPath path
     * @param string $message
     */
    public function assertXpath($path, $message = '')
    {
        $content    = $this->response->outputBody();
        $domQuery = new Zend_Dom_Query($content);
        $domQuery->registerXpathNamespaces($this->_xpathNamespaces);
        $result = $domQuery->queryXpath($path);
        $this->assertNotCount(0, $result, $message);
    }

    /**
     * Assert that response is a redirect
     *
     * @param string $message
     */
    public function assertRedirect($message = '')
    {
        $this->assertTrue($this->response->isRedirect());
    }

    /**
     * Assert that response is NOT a redirect
     *
     * @param string $message
     */
    public function assertNotRedirect($message = '')
    {
        $this->assertFalse($this->response->isRedirect());
    }

    /**
     * Assert that response redirects to given URL
     *
     * @param string $url
     * @param string $message
     */
    public function assertRedirectTo($url, $message = '')
    {
        $this->assertRedirect();
        $headers = $this->response->sendHeaders();
        $this->assertStringContainsString($headers['location'], "Location: $url");
    }

    /**
     * Get node content, minus node markup tags
     *
     * @param  DOMNode $node
     * @return string
     */
    protected function _getNodeContent(DOMNode $node)
    {
        if ($node instanceof DOMAttr) {
            return $node->value;
        } else {
            $doc     = $node->ownerDocument;
            $content = $doc->saveXML($node);
            $tag     = $node->nodeName;
            $regex   = '|</?' . $tag . '[^>]*>|';
            return preg_replace($regex, '', $content);
        }
    }
}
