<?php

/**
 * Slim Framework (https://slimframework.com)
 *
 * @license https://github.com/slimphp/Slim/blob/4.x/LICENSE.md (MIT License)
 */

declare(strict_types=1);

namespace Slim\Tests\Handlers;

use Prophecy\Argument;
use Psr\Http\Message\ResponseInterface;
use Psr\Log\LoggerInterface;
use ReflectionClass;
use ReflectionMethod;
use ReflectionProperty;
use RuntimeException;
use Slim\Error\Renderers\HtmlErrorRenderer;
use Slim\Error\Renderers\JsonErrorRenderer;
use Slim\Error\Renderers\PlainTextErrorRenderer;
use Slim\Error\Renderers\XmlErrorRenderer;
use Slim\Exception\HttpMethodNotAllowedException;
use Slim\Exception\HttpNotFoundException;
use Slim\Handlers\ErrorHandler;
use Slim\Interfaces\CallableResolverInterface;
use Slim\Tests\Mocks\MockCustomException;
use Slim\Tests\TestCase;

class ErrorHandlerTest extends TestCase
{
    private function getMockLogger(): LoggerInterface
    {
        return $this->createMock(LoggerInterface::class);
    }

    public function testDetermineRenderer()
    {
        $handler = $this->createMock(ErrorHandler::class);
        $class = new ReflectionClass(ErrorHandler::class);

        $callableResolverProperty = $class->getProperty('callableResolver');
        $this->setAccessible($callableResolverProperty);
        $callableResolverProperty->setValue($handler, $this->getCallableResolver());

        $reflectionProperty = $class->getProperty('contentType');
        $this->setAccessible($reflectionProperty);
        $reflectionProperty->setValue($handler, 'application/json');

        $method = $class->getMethod('determineRenderer');
        $this->setAccessible($method);

        $renderer = $method->invoke($handler);
        $this->assertIsCallable($renderer);
        $this->assertInstanceOf(JsonErrorRenderer::class, $renderer[0]);

        $reflectionProperty->setValue($handler, 'application/xml');
        $renderer = $method->invoke($handler);
        $this->assertIsCallable($renderer);
        $this->assertInstanceOf(XmlErrorRenderer::class, $renderer[0]);

        $reflectionProperty->setValue($handler, 'text/plain');
        $renderer = $method->invoke($handler);
        $this->assertIsCallable($renderer);
        $this->assertInstanceOf(PlainTextErrorRenderer::class, $renderer[0]);

        // Test the default error renderer
        $reflectionProperty->setValue($handler, 'text/unknown');
        $renderer = $method->invoke($handler);
        $this->assertIsCallable($renderer);
        $this->assertInstanceOf(HtmlErrorRenderer::class, $renderer[0]);
    }

    public function testDetermineStatusCode()
    {
        $request = $this->createServerRequest('/');
        $handler = $this->createMock(ErrorHandler::class);
        $class = new ReflectionClass(ErrorHandler::class);

        $reflectionProperty = $class->getProperty('responseFactory');
        $this->setAccessible($reflectionProperty);
        $reflectionProperty->setValue($handler, $this->getResponseFactory());

        $reflectionProperty = $class->getProperty('exception');
        $this->setAccessible($reflectionProperty);
        $reflectionProperty->setValue($handler, new HttpNotFoundException($request));

        $method = $class->getMethod('determineStatusCode');
        $this->setAccessible($method);

        $statusCode = $method->invoke($handler);
        $this->assertSame($statusCode, 404);

        $reflectionProperty->setValue($handler, new MockCustomException());

        $statusCode = $method->invoke($handler);
        $this->assertSame($statusCode, 500);
    }

    /**
     * Test if we can force the content type of all error handler responses.
     */
    public function testForceContentType()
    {
        $request = $this
            ->createServerRequest('/not-defined', 'GET')
            ->withHeader('Accept', 'text/plain,text/xml');

        $handler = new ErrorHandler($this->getCallableResolver(), $this->getResponseFactory());
        $handler->forceContentType('application/json');

        $exception = new HttpNotFoundException($request);

        /** @var ResponseInterface $response */
        $response = $handler->__invoke($request, $exception, false, false, false);

        $this->assertSame(['application/json'], $response->getHeader('Content-Type'));
    }

    public function testHalfValidContentType()
    {
        $request = $this
            ->createServerRequest('/', 'GET')
            ->withHeader('Content-Type', 'unknown/json+');

        $handler = $this->createMock(ErrorHandler::class);
        $newErrorRenderers = [
            'application/xml' => XmlErrorRenderer::class,
            'text/xml' => XmlErrorRenderer::class,
            'text/html' => HtmlErrorRenderer::class,
        ];

        $class = new ReflectionClass(ErrorHandler::class);

        $reflectionProperty = $class->getProperty('responseFactory');
        $this->setAccessible($reflectionProperty);
        $reflectionProperty->setValue($handler, $this->getResponseFactory());

        $reflectionProperty = $class->getProperty('errorRenderers');
        $this->setAccessible($reflectionProperty);
        $reflectionProperty->setValue($handler, $newErrorRenderers);

        $method = $class->getMethod('determineContentType');
        $this->setAccessible($method);

        $contentType = $method->invoke($handler, $request);

        $this->assertNull($contentType);
    }

    public function testDetermineContentTypeTextPlainMultiAcceptHeader()
    {
        $request = $this
            ->createServerRequest('/', 'GET')
            ->withHeader('Content-Type', 'text/plain')
            ->withHeader('Accept', 'text/plain,text/xml');

        $handler = $this->createMock(ErrorHandler::class);

        $errorRenderers = [
            'text/plain' => PlainTextErrorRenderer::class,
            'text/xml' => XmlErrorRenderer::class,
        ];

        $class = new ReflectionClass(ErrorHandler::class);

        $reflectionProperty = $class->getProperty('responseFactory');
        $this->setAccessible($reflectionProperty);
        $reflectionProperty->setValue($handler, $this->getResponseFactory());

        $reflectionProperty = $class->getProperty('errorRenderers');
        $this->setAccessible($reflectionProperty);
        $reflectionProperty->setValue($handler, $errorRenderers);

        $method = $class->getMethod('determineContentType');
        $this->setAccessible($method);

        $contentType = $method->invoke($handler, $request);

        $this->assertSame('text/xml', $contentType);
    }

    public function testDetermineContentTypeApplicationJsonOrXml()
    {
        $request = $this
            ->createServerRequest('/', 'GET')
            ->withHeader('Content-Type', 'text/json')
            ->withHeader('Accept', 'application/xhtml+xml');

        $handler = $this->createMock(ErrorHandler::class);

        $errorRenderers = [
            'application/xml' => XmlErrorRenderer::class
        ];

        $class = new ReflectionClass(ErrorHandler::class);

        $reflectionProperty = $class->getProperty('responseFactory');
        $this->setAccessible($reflectionProperty);
        $reflectionProperty->setValue($handler, $this->getResponseFactory());

        $reflectionProperty = $class->getProperty('errorRenderers');
        $this->setAccessible($reflectionProperty);
        $reflectionProperty->setValue($handler, $errorRenderers);

        $method = $class->getMethod('determineContentType');
        $this->setAccessible($method);

        $contentType = $method->invoke($handler, $request);

        $this->assertSame('application/xml', $contentType);
    }

    /**
     * Ensure that an acceptable media-type is found in the Accept header even
     * if it's not the first in the list.
     */
    public function testAcceptableMediaTypeIsNotFirstInList()
    {
        $request = $this
            ->createServerRequest('/', 'GET')
            ->withHeader('Accept', 'text/plain,text/html');

        // provide access to the determineContentType() as it's a protected method
        $class = new ReflectionClass(ErrorHandler::class);
        $method = $class->getMethod('determineContentType');
        $this->setAccessible($method);

        // use a mock object here as ErrorHandler cannot be directly instantiated
        $handler = $this->createMock(ErrorHandler::class);

        // call determineContentType()
        $return = $method->invoke($handler, $request);

        $this->assertSame('text/html', $return);
    }

    public function testRegisterErrorRenderer()
    {
        $handler = new ErrorHandler($this->getCallableResolver(), $this->getResponseFactory());
        $handler->registerErrorRenderer('application/slim', PlainTextErrorRenderer::class);

        $reflectionClass = new ReflectionClass(ErrorHandler::class);
        $reflectionProperty = $reflectionClass->getProperty('errorRenderers');
        $this->setAccessible($reflectionProperty);
        $errorRenderers = $reflectionProperty->getValue($handler);

        $this->assertArrayHasKey('application/slim', $errorRenderers);
    }

    public function testSetDefaultErrorRenderer()
    {
        $handler = new ErrorHandler($this->getCallableResolver(), $this->getResponseFactory());
        $handler->setDefaultErrorRenderer('text/plain', PlainTextErrorRenderer::class);

        $reflectionClass = new ReflectionClass(ErrorHandler::class);
        $reflectionProperty = $reflectionClass->getProperty('defaultErrorRenderer');
        $this->setAccessible($reflectionProperty);
        $defaultErrorRenderer = $reflectionProperty->getValue($handler);

        $defaultErrorRendererContentTypeProperty = $reflectionClass->getProperty('defaultErrorRendererContentType');
        $this->setAccessible($defaultErrorRendererContentTypeProperty);
        $defaultErrorRendererContentType = $defaultErrorRendererContentTypeProperty->getValue($handler);

        $this->assertSame(PlainTextErrorRenderer::class, $defaultErrorRenderer);
        $this->assertSame('text/plain', $defaultErrorRendererContentType);
    }

    public function testOptions()
    {
        $request = $this->createServerRequest('/', 'OPTIONS');
        $handler = new ErrorHandler($this->getCallableResolver(), $this->getResponseFactory());
        $exception = new HttpMethodNotAllowedException($request);
        $exception->setAllowedMethods(['POST', 'PUT']);

        /** @var ResponseInterface $res */
        $res = $handler->__invoke($request, $exception, true, false, true);

        $this->assertSame(200, $res->getStatusCode());
        $this->assertTrue($res->hasHeader('Allow'));
        $this->assertSame('POST, PUT', $res->getHeaderLine('Allow'));
    }

    public function testWriteToErrorLog()
    {
        $request = $this
            ->createServerRequest('/', 'GET')
            ->withHeader('Accept', 'application/json');

        $logger = $this->getMockLogger();

        $handler = new ErrorHandler(
            $this->getCallableResolver(),
            $this->getResponseFactory(),
            $logger
        );

        $logger->expects(self::once())
            ->method('error')
            ->willReturnCallback(static function (string $error) {
                self::assertStringNotContainsString(
                    'set "displayErrorDetails" to true in the ErrorHandler constructor',
                    $error
                );
            });

        $exception = new HttpNotFoundException($request);
        $handler->__invoke($request, $exception, true, true, true);
    }

    public function testWriteToErrorLogShowTip()
    {
        $request = $this
            ->createServerRequest('/', 'GET')
            ->withHeader('Accept', 'application/json');

        $logger = $this->getMockLogger();

        $handler = new ErrorHandler(
            $this->getCallableResolver(),
            $this->getResponseFactory(),
            $logger
        );

        $logger->expects(self::once())
            ->method('error')
            ->willReturnCallback(static function (string $error) {
                self::assertStringContainsString(
                    'set "displayErrorDetails" to true in the ErrorHandler constructor',
                    $error
                );
            });

        $exception = new HttpNotFoundException($request);
        $handler->__invoke($request, $exception, false, true, true);
    }

    public function testWriteToErrorLogDoesNotShowTipIfErrorLogRendererIsNotPlainText()
    {
        $request = $this
            ->createServerRequest('/', 'GET')
            ->withHeader('Accept', 'application/json');

        $logger = $this->getMockLogger();

        $handler = new ErrorHandler(
            $this->getCallableResolver(),
            $this->getResponseFactory(),
            $logger
        );

        $handler->setLogErrorRenderer(HtmlErrorRenderer::class);

        $logger->expects(self::once())
            ->method('error')
            ->willReturnCallback(static function (string $error) {
                self::assertStringNotContainsString(
                    'set "displayErrorDetails" to true in the ErrorHandler constructor',
                    $error
                );
            });

        $exception = new HttpNotFoundException($request);
        $handler->__invoke($request, $exception, false, true, true);
    }

    public function testDefaultErrorRenderer()
    {
        $request = $this
            ->createServerRequest('/', 'GET')
            ->withHeader('Accept', 'application/unknown');

        $handler = new ErrorHandler($this->getCallableResolver(), $this->getResponseFactory());
        $exception = new RuntimeException();

        /** @var ResponseInterface $res */
        $res = $handler->__invoke($request, $exception, true, false, true);

        $this->assertTrue($res->hasHeader('Content-Type'));
        $this->assertSame('text/html', $res->getHeaderLine('Content-Type'));
    }

    public function testLogErrorRenderer()
    {
        $renderer = function () {
            return '';
        };

        $callableResolverProphecy = $this->prophesize(CallableResolverInterface::class);
        $callableResolverProphecy
            ->resolve('logErrorRenderer')
            ->willReturn($renderer)
            ->shouldBeCalledOnce();

        $loggerProphecy = $this->prophesize(LoggerInterface::class);
        $loggerProphecy
            ->error(Argument::type('string'))
            ->shouldBeCalled();

        $handler = new ErrorHandler(
            $callableResolverProphecy->reveal(),
            $this->getResponseFactory(),
            $loggerProphecy->reveal()
        );
        $handler->setLogErrorRenderer('logErrorRenderer');

        $displayErrorDetailsProperty = new ReflectionProperty($handler, 'displayErrorDetails');
        $this->setAccessible($displayErrorDetailsProperty);
        $displayErrorDetailsProperty->setValue($handler, true);

        $exception = new RuntimeException();
        $exceptionProperty = new ReflectionProperty($handler, 'exception');
        $this->setAccessible($exceptionProperty);
        $exceptionProperty->setValue($handler, $exception);

        $writeToErrorLogMethod = new ReflectionMethod($handler, 'writeToErrorLog');
        $this->setAccessible($writeToErrorLogMethod);
        $writeToErrorLogMethod->invoke($handler);
    }
}
