<?php
namespace Codeception\Module;

use Phalcon\Di;
use PDOException;
use Phalcon\Mvc\Url;
use Phalcon\DiInterface;
use Phalcon\Di\Injectable;
use Codeception\TestInterface;
use Codeception\Configuration;
use Codeception\Lib\Framework;
use Phalcon\Mvc\RouterInterface;
use Phalcon\Mvc\Model as PhalconModel;
use Phalcon\Mvc\Router\RouteInterface;
use Codeception\Util\ReflectionHelper;
use Codeception\Exception\ModuleException;
use Codeception\Lib\Interfaces\ActiveRecord;
use Codeception\Lib\Interfaces\PartedModule;
use Codeception\Exception\ModuleConfigException;
use Codeception\Lib\Connector\Phalcon as PhalconConnector;

/**
 * This module provides integration with [Phalcon framework](http://www.phalconphp.com/) (3.x).
 * Please try it and leave your feedback.
 *
 * ## Demo Project
 *
 * <https://github.com/Codeception/phalcon-demo>
 *
 * ## Status
 *
 * * Maintainer: **Serghei Iakovlev**
 * * Stability: **stable**
 * * Contact: serghei@phalconphp.com
 *
 * ## Config
 *
 * The following configurations are required for this module:
 *
 * * bootstrap: `string`, default `app/config/bootstrap.php` - relative path to app.php config file
 * * cleanup: `boolean`, default `true` - all database queries will be run in a transaction,
 *   which will be rolled back at the end of each test
 * * savepoints: `boolean`, default `true` - use savepoints to emulate nested transactions
 *
 * The application bootstrap file must return Application object but not call its handle() method.
 *
 * ## API
 *
 * * di - `Phalcon\Di\Injectable` instance
 * * client - `BrowserKit` client
 *
 * ## Parts
 *
 * By default all available methods are loaded, but you can specify parts to select only needed
 * actions and avoid conflicts.
 *
 * * `orm` - include only `haveRecord/grabRecord/seeRecord/dontSeeRecord` actions.
 * * `services` - allows to use `grabServiceFromContainer` and `addServiceToContainer`.
 *
 * Usage example:
 *
 * Sample bootstrap (`app/config/bootstrap.php`):
 *
 * ``` php
 * <?php
 * $config = include __DIR__ . "/config.php";
 * include __DIR__ . "/loader.php";
 * $di = new \Phalcon\DI\FactoryDefault();
 * include __DIR__ . "/services.php";
 * return new \Phalcon\Mvc\Application($di);
 * ?>
 * ```
 *
 * ```yaml
 * class_name: AcceptanceTester
 * modules:
 *     enabled:
 *         - Phalcon:
 *             part: services
 *             bootstrap: 'app/config/bootstrap.php'
 *             cleanup: true
 *             savepoints: true
 *         - WebDriver:
 *             url: http://your-url.com
 *             browser: phantomjs
 * ```
 */
class Phalcon extends Framework implements ActiveRecord, PartedModule
{
    protected $config = [
        'bootstrap'  => 'app/config/bootstrap.php',
        'cleanup'    => true,
        'savepoints' => true,
    ];

    /**
     * Phalcon bootstrap file path
     */
    protected $bootstrapFile = null;

    /**
     * Dependency injection container
     * @var DiInterface
     */
    public $di = null;

    /**
     * Phalcon Connector
     * @var PhalconConnector
     */
    public $client;

    /**
     * HOOK: used after configuration is loaded
     *
     * @throws ModuleConfigException
     */
    public function _initialize()
    {
        $this->bootstrapFile = Configuration::projectDir() . $this->config['bootstrap'];

        if (!file_exists($this->bootstrapFile)) {
            throw new ModuleConfigException(
                __CLASS__,
                "Bootstrap file does not exist in " . $this->config['bootstrap'] . "\n"
                . "Please create the bootstrap file that returns Application object\n"
                . "And specify path to it with 'bootstrap' config\n\n"
                . "Sample bootstrap: \n\n<?php\n"
                . '$config = include __DIR__ . "/config.php";' . "\n"
                . 'include __DIR__ . "/loader.php";' . "\n"
                . '$di = new \Phalcon\DI\FactoryDefault();' . "\n"
                . 'include __DIR__ . "/services.php";' . "\n"
                . 'return new \Phalcon\Mvc\Application($di);'
            );
        }

        $this->client = new PhalconConnector();
    }

    /**
     * HOOK: before scenario
     *
     * @param TestInterface $test
     * @throws ModuleException
     */
    public function _before(TestInterface $test)
    {
        /** @noinspection PhpIncludeInspection */
        $application = require $this->bootstrapFile;
        if (!$application instanceof Injectable) {
            throw new ModuleException(__CLASS__, 'Bootstrap must return \Phalcon\Di\Injectable object');
        }

        $this->di = $application->getDI();

        Di::reset();
        Di::setDefault($this->di);

        if ($this->di->has('session')) {
            // Destroy existing sessions of previous tests
            $this->di['session'] = new PhalconConnector\MemorySession();
        }

        if ($this->di->has('cookies')) {
            $this->di['cookies']->useEncryption(false);
        }

        if ($this->config['cleanup'] && $this->di->has('db')) {
            if ($this->config['savepoints']) {
                $this->di['db']->setNestedTransactionsWithSavepoints(true);
            }
            $this->di['db']->begin();
        }

        // localize
        $bootstrap = $this->bootstrapFile;
        $this->client->setApplication(function () use ($bootstrap) {
            $currentDi = Di::getDefault();
            /** @noinspection PhpIncludeInspection */
            $application = require $bootstrap;
            $di = $application->getDI();
            if ($currentDi->has('db')) {
                $di['db'] = $currentDi['db'];
            }
            if ($currentDi->has('session')) {
                $di['session'] = $currentDi['session'];
            }
            if ($di->has('cookies')) {
                $di['cookies']->useEncryption(false);
            }
            return $application;
        });
    }

    /**
     * HOOK: after scenario
     *
     * @param TestInterface $test
     */
    public function _after(TestInterface $test)
    {
        if ($this->config['cleanup'] && isset($this->di['db'])) {
            while ($this->di['db']->isUnderTransaction()) {
                $level = $this->di['db']->getTransactionLevel();
                try {
                    $this->di['db']->rollback(true);
                } catch (PDOException $e) {
                }
                if ($level == $this->di['db']->getTransactionLevel()) {
                    break;
                }
            }
            $this->di['db']->close();
        }
        $this->di = null;
        Di::reset();

        $_SESSION = $_FILES = $_GET = $_POST = $_COOKIE = $_REQUEST = [];
    }

    public function _parts()
    {
        return ['orm', 'services'];
    }

    /**
     * Provides access the Phalcon application object.
     *
     * @see \Codeception\Lib\Connector\Phalcon::getApplication
     * @return \Phalcon\Application|\Phalcon\Mvc\Micro
     */
    public function getApplication()
    {
        return $this->client->getApplication();
    }

    /**
     * Sets value to session. Use for authorization.
     *
     * @param string $key
     * @param mixed $val
     */
    public function haveInSession($key, $val)
    {
        $this->di->get('session')->set($key, $val);
        $this->debugSection('Session', json_encode($this->di['session']->toArray()));
    }

    /**
     * Checks that session contains value.
     * If value is `null` checks that session has key.
     *
     * ``` php
     * <?php
     * $I->seeInSession('key');
     * $I->seeInSession('key', 'value');
     * ?>
     * ```
     *
     * @param string $key
     * @param mixed $value
     */
    public function seeInSession($key, $value = null)
    {
        $this->debugSection('Session', json_encode($this->di['session']->toArray()));

        if (is_array($key)) {
            $this->seeSessionHasValues($key);
            return;
        }

        if (!$this->di['session']->has($key)) {
            $this->fail("No session variable with key '$key'");
        }

        if (is_null($value)) {
            $this->assertTrue($this->di['session']->has($key));
        } else {
            $this->assertEquals($value, $this->di['session']->get($key));
        }
    }

    /**
     * Assert that the session has a given list of values.
     *
     * ``` php
     * <?php
     * $I->seeSessionHasValues(['key1', 'key2']);
     * $I->seeSessionHasValues(['key1' => 'value1', 'key2' => 'value2']);
     * ?>
     * ```
     *
     * @param  array $bindings
     * @return void
     */
    public function seeSessionHasValues(array $bindings)
    {
        foreach ($bindings as $key => $value) {
            if (is_int($key)) {
                $this->seeInSession($value);
            } else {
                $this->seeInSession($key, $value);
            }
        }
    }

    /**
     * Inserts record into the database.
     *
     * ``` php
     * <?php
     * $user_id = $I->haveRecord('App\Models\Users', ['name' => 'Phalcon']);
     * $I->haveRecord('App\Models\Categories', ['name' => 'Testing']');
     * ?>
     * ```
     *
     * @param string $model Model name
     * @param array $attributes Model attributes
     * @return mixed
     * @part orm
     */
    public function haveRecord($model, $attributes = [])
    {
        $record = $this->getModelRecord($model);
        $res = $record->save($attributes);
        $field = function ($field) {
            if (is_array($field)) {
                return implode(', ', $field);
            }

            return $field;
        };

        if (!$res) {
            $messages = $record->getMessages();
            $errors = [];
            foreach ($messages as $message) {
                /** @var \Phalcon\Mvc\Model\MessageInterface $message */
                $errors[] = sprintf(
                    '[%s] %s: %s',
                    $message->getType(),
                    $field($message->getField()),
                    $message->getMessage()
                );
            }

            $this->fail(sprintf("Record %s was not saved. Messages: \n%s", $model, implode(PHP_EOL, $errors)));

            return null;
        }

        $this->debugSection($model, json_encode($record));

        return $this->getModelIdentity($record);
    }

    /**
     * Checks that record exists in database.
     *
     * ``` php
     * <?php
     * $I->seeRecord('App\Models\Categories', ['name' => 'Testing']);
     * ?>
     * ```
     *
     * @param string $model Model name
     * @param array  $attributes Model attributes
     * @part orm
     */
    public function seeRecord($model, $attributes = [])
    {
        $record = $this->findRecord($model, $attributes);
        if (!$record) {
            $this->fail("Couldn't find $model with " . json_encode($attributes));
        }
        $this->debugSection($model, json_encode($record));
    }

    /**
     * Checks that record does not exist in database.
     *
     * ``` php
     * <?php
     * $I->dontSeeRecord('App\Models\Categories', ['name' => 'Testing']);
     * ?>
     * ```
     *
     * @param string $model Model name
     * @param array $attributes Model attributes
     * @part orm
     */
    public function dontSeeRecord($model, $attributes = [])
    {
        $record = $this->findRecord($model, $attributes);
        $this->debugSection($model, json_encode($record));
        if ($record) {
            $this->fail("Unexpectedly managed to find $model with " . json_encode($attributes));
        }
    }

    /**
     * Retrieves record from database
     *
     * ``` php
     * <?php
     * $category = $I->grabRecord('App\Models\Categories', ['name' => 'Testing']);
     * ?>
     * ```
     *
     * @param string $model Model name
     * @param array  $attributes Model attributes
     * @return mixed
     * @part orm
     */
    public function grabRecord($model, $attributes = [])
    {
        return $this->findRecord($model, $attributes);
    }

    /**
     * Resolves the service based on its configuration from Phalcon's DI container
     * Recommended to use for unit testing.
     *
     * @param string $service    Service name
     * @param array  $parameters Parameters [Optional]
     * @return mixed
     * @part services
     */
    public function grabServiceFromContainer($service, array $parameters = [])
    {
        if (!$this->di->has($service)) {
            $this->fail("Service $service is not available in container");
        }

        return $this->di->get($service, $parameters);
    }

    /**
     * Alias for `grabServiceFromContainer`.
     *
     * Note: Deprecated. Will be removed in Codeception 2.3.
     *
     * @param string $service    Service name
     * @param array  $parameters Parameters [Optional]
     * @return mixed
     * @part services
     */
    public function grabServiceFromDi($service, array $parameters = [])
    {
        return $this->grabServiceFromContainer($service, $parameters);
    }

    /**
     * Registers a service in the services container and resolve it. This record will be erased after the test.
     * Recommended to use for unit testing.
     *
     * ``` php
     * <?php
     * $filter = $I->addServiceToContainer('filter', ['className' => '\Phalcon\Filter']);
     * $filter = $I->addServiceToContainer('answer', function () {
     *      return rand(0, 1) ? 'Yes' : 'No';
     * }, true);
     * ?>
     * ```
     *
     * @param string $name
     * @param mixed $definition
     * @param boolean $shared
     * @return mixed|null
     * @part services
     */
    public function addServiceToContainer($name, $definition, $shared = false)
    {
        try {
            $service = $this->di->set($name, $definition, $shared);
            return $service->resolve();
        } catch (\Exception $e) {
            $this->fail($e->getMessage());

            return null;
        }
    }

    /**
     * Alias for `addServiceToContainer`.
     *
     * Note: Deprecated. Will be removed in Codeception 2.3.
     *
     * @param string $name
     * @param mixed $definition
     * @param boolean $shared
     * @return mixed|null
     * @part services
     */
    public function haveServiceInDi($name, $definition, $shared = false)
    {
        return $this->addServiceToContainer($name, $definition, $shared);
    }

    /**
     * Opens web page using route name and parameters.
     *
     * ``` php
     * <?php
     * $I->amOnRoute('posts.create');
     * ?>
     * ```
     *
     * @param string $routeName
     * @param array  $params
     */
    public function amOnRoute($routeName, array $params = [])
    {
        if (!$this->di->has('url')) {
            $this->fail('Unable to resolve "url" service.');
        }

        /** @var Url $url */
        $url = $this->di->getShared('url');

        $urlParams = ['for' => $routeName];

        if ($params) {
            $urlParams += $params;
        }

        $this->amOnPage($url->get($urlParams, null, true));
    }

    /**
     * Checks that current url matches route
     *
     * ``` php
     * <?php
     * $I->seeCurrentRouteIs('posts.index');
     * ?>
     * ```
     * @param string $routeName
     */
    public function seeCurrentRouteIs($routeName)
    {
        if (!$this->di->has('url')) {
            $this->fail('Unable to resolve "url" service.');
        }

        /** @var Url $url */
        $url = $this->di->getShared('url');
        $this->seeCurrentUrlEquals($url->get(['for' => $routeName], null, true));
    }

    /**
     * Allows to query the first record that match the specified conditions
     *
     * @param string $model Model name
     * @param array $attributes Model attributes
     *
     * @return \Phalcon\Mvc\Model
     */
    protected function findRecord($model, $attributes = [])
    {
        $this->getModelRecord($model);
        $query = [];
        foreach ($attributes as $key => $value) {
            $query[] = "$key = '$value'";
        }
        $squery = implode(' AND ', $query);
        $this->debugSection('Query', $squery);
        return call_user_func_array([$model, 'findFirst'], [$squery]);
    }

    /**
     * Get Model Record
     *
     * @param $model
     *
     * @return \Phalcon\Mvc\Model
     * @throws ModuleException
     */
    protected function getModelRecord($model)
    {
        if (!class_exists($model)) {
            throw new ModuleException(__CLASS__, "Model $model does not exist");
        }

        $record = new $model;
        if (!$record instanceof PhalconModel) {
            throw new ModuleException(__CLASS__, "Model $model is not instance of \\Phalcon\\Mvc\\Model");
        }

        return $record;
    }

    /**
     * Get identity.
     *
     * @param \Phalcon\Mvc\Model $model
     * @return mixed
     */
    protected function getModelIdentity(PhalconModel $model)
    {
        if (property_exists($model, 'id')) {
            return $model->id;
        }

        if (!$this->di->has('modelsMetadata')) {
            return null;
        }

        $primaryKeys = $this->di->get('modelsMetadata')->getPrimaryKeyAttributes($model);

        switch (count($primaryKeys)) {
            case 0:
                return null;
            case 1:
                return $model->{$primaryKeys[0]};
            default:
                return array_intersect_key(get_object_vars($model), array_flip($primaryKeys));
        }
    }

    /**
     * Returns a list of recognized domain names
     *
     * @return array
     */
    protected function getInternalDomains()
    {
        $internalDomains = [$this->getApplicationDomainRegex()];

        /** @var RouterInterface $router */
        $router = $this->di->get('router');

        if ($router instanceof RouterInterface) {
            /** @var RouteInterface[] $routes */
            $routes = $router->getRoutes();

            foreach ($routes as $route) {
                if ($route instanceof RouteInterface) {
                    $hostName = $route->getHostname();
                    if (!empty($hostName)) {
                        $internalDomains[] = '/^' . str_replace('.', '\.', $route->getHostname()) . '$/';
                    }
                }
            }
        }

        return array_unique($internalDomains);
    }

    /**
     * @return string
     */
    private function getApplicationDomainRegex()
    {
        $server = ReflectionHelper::readPrivateProperty($this->client, 'server');
        $domain = $server['HTTP_HOST'];

        return '/^' . str_replace('.', '\.', $domain) . '$/';
    }
}
