<?php

declare(strict_types=1);

/**
 * This file is part of CodeIgniter 4 framework.
 *
 * (c) CodeIgniter Foundation <admin@codeigniter.com>
 *
 * For the full copyright and license information, please view
 * the LICENSE file that was distributed with this source code.
 */

namespace CodeIgniter\Test;

use Closure;
use CodeIgniter\Events\Events;
use CodeIgniter\Exceptions\RuntimeException;
use CodeIgniter\HTTP\Exceptions\RedirectException;
use CodeIgniter\HTTP\IncomingRequest;
use CodeIgniter\HTTP\Method;
use CodeIgniter\HTTP\Request;
use CodeIgniter\HTTP\ResponseInterface;
use CodeIgniter\HTTP\SiteURI;
use CodeIgniter\HTTP\URI;
use CodeIgniter\Router\RouteCollection;
use Config\App;
use Config\Services;
use Exception;
use ReflectionException;

/**
 * Trait FeatureTestTrait
 *
 * Provides additional utilities for doing full HTTP testing
 * against your application in trait format.
 *
 * @property array<int|string, mixed>           $session
 * @property array<string, list<string>|string> $headers
 * @property RouteCollection|null               $routes
 *
 * @mixin CIUnitTestCase
 */
trait FeatureTestTrait
{
    /**
     * Sets a RouteCollection that will override
     * the application's route collection.
     *
     * Example routes:
     * [
     *    ['GET', 'home', 'Home::index'],
     * ]
     *
     * @param array<int, array{
     *      0: string,
     *      1: string,
     *      2: ((Closure(mixed...): (ResponseInterface|string|void)))|string,
     *      3?: array<string, mixed>
     *  }>|null $routes Array to set routes
     *
     * @return $this
     */
    protected function withRoutes(?array $routes = null)
    {
        $collection = service('routes');

        if ($routes !== null) {
            $collection->resetRoutes();

            foreach ($routes as $route) {
                if ($route[0] === strtolower($route[0])) {
                    @trigger_error(
                        'Passing lowercase HTTP method "' . $route[0] . '" is deprecated.'
                        . ' Use uppercase HTTP method like "' . strtoupper($route[0]) . '".',
                        E_USER_DEPRECATED,
                    );
                }

                // @todo v4.7.1 Remove the strtoupper() and use 'add' in v4.8.0
                if (! in_array(strtoupper($route[0]), ['ADD', 'CLI', ...Method::all()], true)) {
                    throw new RuntimeException(sprintf(
                        'Invalid HTTP method "%s" provided for route "%s".',
                        $route[0],
                        $route[1],
                    ));
                }

                $method = strtolower($route[0]); // convert to method of RouteCollection

                if (isset($route[3])) {
                    $collection->{$method}($route[1], $route[2], $route[3]);
                } else {
                    $collection->{$method}($route[1], $route[2]);
                }
            }
        }

        $this->routes = $collection;

        return $this;
    }

    /**
     * Sets any values that should exist during this session.
     *
     * @param array<int|string, mixed>|null $values Array of values, or null to use the current $_SESSION
     *
     * @return $this
     */
    public function withSession(?array $values = null)
    {
        $this->session = $values ?? $_SESSION;

        return $this;
    }

    /**
     * Set request's headers
     *
     * Example of use
     * withHeaders([
     *     'Authorization' => 'Token',
     *     'Cache-Control' => ['no-cache', 'no-store'],
     * ])
     *
     * @param array<string, list<string>|string> $headers Array of headers
     *
     * @return $this
     */
    public function withHeaders(array $headers = [])
    {
        $this->headers = $headers;

        return $this;
    }

    /**
     * Set the format the request's body should have.
     *
     * @param string $format The desired format. Currently supported formats: xml, json
     *
     * @return $this
     */
    public function withBodyFormat(string $format)
    {
        $this->bodyFormat = $format;

        return $this;
    }

    /**
     * Set the raw body for the request
     *
     * @param string $body
     *
     * @return $this
     */
    public function withBody($body)
    {
        $this->requestBody = $body;

        return $this;
    }

    /**
     * Don't run any events while running this test.
     *
     * @return $this
     */
    public function skipEvents()
    {
        Events::simulate(true);

        return $this;
    }

    /**
     * Calls a single URI, executes it, and returns a TestResponse
     * instance that can be used to run many assertions against.
     *
     * @param string $method HTTP verb
     *
     * @return TestResponse
     */
    public function call(string $method, string $path, ?array $params = null)
    {
        if ($method === strtolower($method)) {
            @trigger_error(
                'Passing lowercase HTTP method "' . $method . '" is deprecated.'
                . ' Use uppercase HTTP method like "' . strtoupper($method) . '".',
                E_USER_DEPRECATED,
            );
        }

        /**
         * @deprecated 4.5.0
         * @TODO remove this in the future.
         */
        $method = strtoupper($method);

        // Simulate having a blank session
        $_SESSION = [];
        service('superglobals')->setServer('REQUEST_METHOD', $method);

        $request = $this->setupRequest($method, $path);
        $request = $this->setupHeaders($request);
        $name    = strtolower($method);
        $request = $this->populateGlobals($name, $request, $params);
        $request = $this->setRequestBody($request, $params);

        // Initialize the RouteCollection
        $routes = $this->routes;

        if ($routes !== []) {
            $routes = service('routes')->loadRoutes();
        }

        $routes->setHTTPVerb($method);

        // Make sure any other classes that might call the request
        // instance get the right one.
        Services::injectMock('request', $request);

        // Make sure filters are reset between tests
        Services::injectMock('filters', service('filters', null, false));

        // Make sure validation is reset between tests
        Services::injectMock('validation', service('validation', null, false));

        $response = $this->app
            ->setContext('web')
            ->setRequest($request)
            ->run($routes, true);

        // Reset directory if it has been set
        service('router')->setDirectory();

        return new TestResponse($response);
    }

    /**
     * Performs a GET request.
     *
     * @param string $path URI path relative to baseURL. May include query.
     *
     * @return TestResponse
     *
     * @throws RedirectException
     * @throws Exception
     */
    public function get(string $path, ?array $params = null)
    {
        return $this->call(Method::GET, $path, $params);
    }

    /**
     * Performs a POST request.
     *
     * @return TestResponse
     *
     * @throws RedirectException
     * @throws Exception
     */
    public function post(string $path, ?array $params = null)
    {
        return $this->call(Method::POST, $path, $params);
    }

    /**
     * Performs a PUT request
     *
     * @return TestResponse
     *
     * @throws RedirectException
     * @throws Exception
     */
    public function put(string $path, ?array $params = null)
    {
        return $this->call(Method::PUT, $path, $params);
    }

    /**
     * Performss a PATCH request
     *
     * @return TestResponse
     *
     * @throws RedirectException
     * @throws Exception
     */
    public function patch(string $path, ?array $params = null)
    {
        return $this->call(Method::PATCH, $path, $params);
    }

    /**
     * Performs a DELETE request.
     *
     * @return TestResponse
     *
     * @throws RedirectException
     * @throws Exception
     */
    public function delete(string $path, ?array $params = null)
    {
        return $this->call(Method::DELETE, $path, $params);
    }

    /**
     * Performs an OPTIONS request.
     *
     * @return TestResponse
     *
     * @throws RedirectException
     * @throws Exception
     */
    public function options(string $path, ?array $params = null)
    {
        return $this->call(Method::OPTIONS, $path, $params);
    }

    /**
     * Setup a Request object to use so that CodeIgniter
     * won't try to auto-populate some of the items.
     *
     * @param string $method HTTP verb
     */
    protected function setupRequest(string $method, ?string $path = null): IncomingRequest
    {
        $config = config(App::class);
        $uri    = new SiteURI($config);

        // $path may have a query in it
        $path  = URI::removeDotSegments($path);
        $parts = explode('?', $path);
        $path  = $parts[0];
        $query = $parts[1] ?? '';

        $superglobals = service('superglobals');
        $superglobals->setServer('QUERY_STRING', $query);

        $uri->setPath($path);
        $uri->setQuery($query);

        Services::injectMock('uri', $uri);

        $request = service('incomingrequest', $config, false);

        $request->setMethod($method);
        $request->setProtocolVersion('1.1');

        if ($config->forceGlobalSecureRequests) {
            $_SERVER['HTTPS'] = 'test';
            $server           = $request->getServer();
            $server['HTTPS']  = 'test';
            $request->setGlobal('server', $server);
        }

        return $request;
    }

    /**
     * Setup the custom request's headers
     *
     * @return IncomingRequest
     */
    protected function setupHeaders(IncomingRequest $request)
    {
        if (! empty($this->headers)) {
            foreach ($this->headers as $name => $value) {
                $request->setHeader($name, $value);
            }
        }

        return $request;
    }

    /**
     * Populates the data of our Request with "global" data
     * relevant to the request, like $_POST data.
     *
     * Always populate the GET vars based on the URI.
     *
     * @param string               $name   Superglobal name (lowercase)
     * @param non-empty-array|null $params
     *
     * @return Request
     *
     * @throws ReflectionException
     */
    protected function populateGlobals(string $name, Request $request, ?array $params = null)
    {
        // $params should set the query vars if present,
        // otherwise set it from the URL.
        $get = ($params !== null && $params !== [] && $name === 'get')
            ? $params
            : $this->getPrivateProperty($request->getUri(), 'query');

        $request->setGlobal('get', $get);

        if ($name === 'get') {
            $request->setGlobal('request', $request->fetchGlobal('get'));
        }

        if ($name === 'post') {
            $request->setGlobal($name, $params ?? []);
            $request->setGlobal(
                'request',
                (array) $request->fetchGlobal('post') + (array) $request->fetchGlobal('get'),
            );
        }

        $_SESSION = $this->session;

        return $request;
    }

    /**
     * Set the request's body formatted according to the value in $this->bodyFormat.
     * This allows the body to be formatted in a way that the controller is going to
     * expect as in the case of testing a JSON or XML API.
     *
     * @param array|null $params The parameters to be formatted and put in the body.
     */
    protected function setRequestBody(Request $request, ?array $params = null): Request
    {
        if ($this->requestBody !== '') {
            $request->setBody($this->requestBody);
        }

        if ($this->bodyFormat !== '') {
            $formatMime = '';
            if ($this->bodyFormat === 'json') {
                $formatMime = 'application/json';
            } elseif ($this->bodyFormat === 'xml') {
                $formatMime = 'application/xml';
            }

            if ($formatMime !== '') {
                $request->setHeader('Content-Type', $formatMime);
            }

            if ($params !== null && $formatMime !== '') {
                $formatted = service('format')->getFormatter($formatMime)->format($params);
                // "withBodyFormat() and $params of call()" has higher priority than withBody().
                $request->setBody($formatted);
            }
        }

        return $request;
    }
}
