<?php
/**
 * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
 * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
 *
 * Licensed under The MIT License
 * For full copyright and license information, please see the LICENSE.txt
 * Redistributions of files must retain the above copyright notice.
 *
 * @copyright     Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
 * @link          https://cakephp.org CakePHP(tm) Project
 * @since         3.3.0
 * @license       https://opensource.org/licenses/mit-license.php MIT License
 */
namespace Cake\Error\Middleware;

use Cake\Core\App;
use Cake\Core\Configure;
use Cake\Core\Exception\Exception as CakeException;
use Cake\Core\InstanceConfigTrait;
use Cake\Error\ExceptionRenderer;
use Cake\Error\PHP7ErrorException;
use Cake\Log\Log;
use Error;
use Exception;
use Throwable;

/**
 * Error handling middleware.
 *
 * Traps exceptions and converts them into HTML or content-type appropriate
 * error pages using the CakePHP ExceptionRenderer.
 */
class ErrorHandlerMiddleware
{
    use InstanceConfigTrait;

    /**
     * Default configuration values.
     *
     * - `log` Enable logging of exceptions.
     * - `skipLog` List of exceptions to skip logging. Exceptions that
     *   extend one of the listed exceptions will also not be logged. Example:
     *
     *   ```
     *   'skipLog' => ['Cake\Error\NotFoundException', 'Cake\Error\UnauthorizedException']
     *   ```
     *
     * - `trace` Should error logs include stack traces?
     *
     * @var array
     */
    protected $_defaultConfig = [
        'skipLog' => [],
        'log' => true,
        'trace' => false,
    ];

    /**
     * Exception render.
     *
     * @var \Cake\Error\ExceptionRendererInterface|callable|string|null
     */
    protected $exceptionRenderer;

    /**
     * Constructor
     *
     * @param string|callable|null $exceptionRenderer The renderer or class name
     *   to use or a callable factory. If null, Configure::read('Error.exceptionRenderer')
     *   will be used.
     * @param array $config Configuration options to use. If empty, `Configure::read('Error')`
     *   will be used.
     */
    public function __construct($exceptionRenderer = null, array $config = [])
    {
        if ($exceptionRenderer) {
            $this->exceptionRenderer = $exceptionRenderer;
        }

        $config = $config ?: Configure::read('Error');
        $this->setConfig($config);

        if (PHP_VERSION_ID >= 70400 && Configure::read('debug')) {
            ini_set('zend.exception_ignore_args', 0);
        }
    }

    /**
     * Wrap the remaining middleware with error handling.
     *
     * @param \Psr\Http\Message\ServerRequestInterface $request The request.
     * @param \Psr\Http\Message\ResponseInterface $response The response.
     * @param callable $next Callback to invoke the next middleware.
     * @return \Psr\Http\Message\ResponseInterface A response
     */
    public function __invoke($request, $response, $next)
    {
        try {
            return $next($request, $response);
        } catch (Throwable $exception) {
            return $this->handleException($exception, $request, $response);
        } catch (Exception $exception) {
            return $this->handleException($exception, $request, $response);
        }
    }

    /**
     * Handle an exception and generate an error response
     *
     * @param \Exception $exception The exception to handle.
     * @param \Psr\Http\Message\ServerRequestInterface $request The request.
     * @param \Psr\Http\Message\ResponseInterface $response The response.
     * @return \Psr\Http\Message\ResponseInterface A response
     */
    public function handleException($exception, $request, $response)
    {
        $renderer = $this->getRenderer($exception, $request);
        try {
            $res = $renderer->render();
            $this->logException($request, $exception);

            return $res;
        } catch (Throwable $exception) {
            $this->logException($request, $exception);
            $response = $this->handleInternalError($response);
        } catch (Exception $exception) {
            $this->logException($request, $exception);
            $response = $this->handleInternalError($response);
        }

        return $response;
    }

    /**
     * @param \Psr\Http\Message\ResponseInterface $response The response
     * @return \Psr\Http\Message\ResponseInterface A response
     */
    protected function handleInternalError($response)
    {
        $body = $response->getBody();
        $body->write('An Internal Server Error Occurred');

        return $response->withStatus(500)
            ->withBody($body);
    }

    /**
     * Get a renderer instance
     *
     * @param \Exception $exception The exception being rendered.
     * @param \Psr\Http\Message\ServerRequestInterface $request The request.
     * @return \Cake\Error\ExceptionRendererInterface The exception renderer.
     * @throws \Exception When the renderer class cannot be found.
     */
    protected function getRenderer($exception, $request)
    {
        if (!$this->exceptionRenderer) {
            $this->exceptionRenderer = $this->getConfig('exceptionRenderer') ?: ExceptionRenderer::class;
        }

        // For PHP5 backwards compatibility
        if ($exception instanceof Error) {
            $exception = new PHP7ErrorException($exception);
        }

        if (is_string($this->exceptionRenderer)) {
            $class = App::className($this->exceptionRenderer, 'Error');
            if (!$class) {
                throw new Exception(sprintf(
                    "The '%s' renderer class could not be found.",
                    $this->exceptionRenderer
                ));
            }

            return new $class($exception, $request);
        }
        $factory = $this->exceptionRenderer;

        return $factory($exception, $request);
    }

    /**
     * Log an error for the exception if applicable.
     *
     * @param \Psr\Http\Message\ServerRequestInterface $request The current request.
     * @param \Exception $exception The exception to log a message for.
     * @return void
     */
    protected function logException($request, $exception)
    {
        if (!$this->getConfig('log')) {
            return;
        }

        foreach ((array)$this->getConfig('skipLog') as $class) {
            if ($exception instanceof $class) {
                return;
            }
        }

        Log::error($this->getMessage($request, $exception));
    }

    /**
     * Generate the error log message.
     *
     * @param \Psr\Http\Message\ServerRequestInterface $request The current request.
     * @param \Exception $exception The exception to log a message for.
     * @return string Error message
     */
    protected function getMessage($request, $exception)
    {
        $message = $this->getMessageForException($exception);

        $message .= "\nRequest URL: " . $request->getRequestTarget();
        $referer = $request->getHeaderLine('Referer');
        if ($referer) {
            $message .= "\nReferer URL: " . $referer;
        }
        $message .= "\n\n";

        return $message;
    }

    /**
     * Generate the message for the exception
     *
     * @param \Exception $exception The exception to log a message for.
     * @param bool $isPrevious False for original exception, true for previous
     * @return string Error message
     */
    protected function getMessageForException($exception, $isPrevious = false)
    {
        $message = sprintf(
            '%s[%s] %s (%s:%s)',
            $isPrevious ? "\nCaused by: " : '',
            get_class($exception),
            $exception->getMessage(),
            $exception->getFile(),
            $exception->getLine()
        );
        $debug = Configure::read('debug');

        if ($debug && $exception instanceof CakeException) {
            $attributes = $exception->getAttributes();
            if ($attributes) {
                $message .= "\nException Attributes: " . var_export($exception->getAttributes(), true);
            }
        }

        if ($this->getConfig('trace')) {
            $message .= "\n" . $exception->getTraceAsString();
        }

        $previous = $exception->getPrevious();
        if ($previous) {
            $message .= $this->getMessageForException($previous, true);
        }

        return $message;
    }
}
