<?php

/**
 * @package    Grav\Common
 *
 * @copyright  Copyright (c) 2015 - 2026 Trilby Media, LLC. All rights reserved.
 * @license    MIT License; see LICENSE file for details.
 */

namespace Grav\Common;

use Composer\Autoload\ClassLoader;
use Grav\Common\Config\CompiledBase;
use Grav\Common\Config\Config;
use Grav\Common\Config\Setup;
use Grav\Common\Helpers\Exif;
use Grav\Common\Page\Interfaces\PageInterface;
use Grav\Common\Page\Markdown\MarkdownOutput;
use Grav\Common\Page\Medium\ImageFile;
use Grav\Common\Page\Medium\ImageMedium;
use Grav\Common\Page\Medium\Medium;
use Grav\Common\Page\Pages;
use Grav\Common\Processors\AssetsProcessor;
use Grav\Common\Processors\BackupsProcessor;
use Grav\Common\Processors\DebuggerAssetsProcessor;
use Grav\Common\Processors\InitializeProcessor;
use Grav\Common\Processors\PagesProcessor;
use Grav\Common\Processors\PluginsProcessor;
use Grav\Common\Processors\RenderProcessor;
use Grav\Common\Processors\RequestProcessor;
use Grav\Common\Processors\SchedulerProcessor;
use Grav\Common\Processors\TasksProcessor;
use Grav\Common\Processors\ThemesProcessor;
use Grav\Common\Processors\TwigProcessor;
use Grav\Common\Scheduler\Scheduler;
use Grav\Common\Service\AccountsServiceProvider;
use Grav\Common\Service\AssetsServiceProvider;
use Grav\Common\Service\BackupsServiceProvider;
use Grav\Common\Service\ConfigServiceProvider;
use Grav\Common\Service\ErrorServiceProvider;
use Grav\Common\Service\FilesystemServiceProvider;
use Grav\Common\Service\FlexServiceProvider;
use Grav\Common\Service\InflectorServiceProvider;
use Grav\Common\Service\LoggerServiceProvider;
use Grav\Common\Service\OutputServiceProvider;
use Grav\Common\Service\PagesServiceProvider;
use Grav\Common\Service\RequestServiceProvider;
use Grav\Common\Service\SessionServiceProvider;
use Grav\Common\Service\StreamsServiceProvider;
use Grav\Common\Service\TaskServiceProvider;
use Grav\Common\Twig\Twig;
use Grav\Framework\DI\Container;
use Grav\Framework\Psr7\Response;
use Grav\Framework\RequestHandler\Middlewares\MultipartRequestSupport;
use Grav\Framework\RequestHandler\RequestHandler;
use Grav\Framework\Route\Route;
use Grav\Framework\Session\Messages;
use InvalidArgumentException;
use RangeException;
use RuntimeException;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\StreamInterface;
use RocketTheme\Toolbox\Event\Event;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use function array_key_exists;
use function call_user_func_array;
use function function_exists;
use function get_class;
use function in_array;
use function is_array;
use function is_callable;
use function is_int;
use function is_string;
use function strlen;

/**
 * Grav container is the heart of Grav.
 *
 * @package Grav\Common
 */
class Grav extends Container
{
    /**
     * Response bodies larger than this (or of unknown length) are streamed to
     * the client in chunks instead of being cast to a string and echoed, which
     * would otherwise buffer the whole body in memory. This keeps large file
     * downloads (e.g. site backups) from exhausting `memory_limit`.
     */
    protected const STREAM_BODY_THRESHOLD = 2097152; // 2 MB

    /** @var int Read size when streaming a response body directly to the client. */
    protected const STREAM_BODY_CHUNK = 65536; // 64 KB

    /** @var string Processed output for the page. */
    public $output;

    /** @var static The singleton instance */
    protected static $instance;

    /**
     * Whether the shutdown handler has been registered for this request. Both
     * the normal page path and close() register it, and a plugin that calls
     * close() from inside onShutdown must not queue a second run.
     *
     * @var bool
     */
    protected $shutdownRegistered = false;

    /**
     * @var array Contains all Services and ServicesProviders that are mapped
     *            to the dependency injection container.
     */
    protected static $diMap = [
        AccountsServiceProvider::class,
        AssetsServiceProvider::class,
        BackupsServiceProvider::class,
        ConfigServiceProvider::class,
        ErrorServiceProvider::class,
        FilesystemServiceProvider::class,
        FlexServiceProvider::class,
        InflectorServiceProvider::class,
        LoggerServiceProvider::class,
        OutputServiceProvider::class,
        PagesServiceProvider::class,
        RequestServiceProvider::class,
        SessionServiceProvider::class,
        StreamsServiceProvider::class,
        TaskServiceProvider::class,
        'browser'    => Browser::class,
        'cache'      => Cache::class,
        'events'     => EventDispatcher::class,
        'exif'       => Exif::class,
        'plugins'    => Plugins::class,
        'scheduler'  => Scheduler::class,
        'taxonomy'   => Taxonomy::class,
        'themes'     => Themes::class,
        'twig'       => Twig::class,
        'uri'        => Uri::class,
    ];

    /**
     * @var array All middleware processors that are processed in $this->process()
     */
    protected $middleware = [
        'multipartRequestSupport',
        'initializeProcessor',
        'pluginsProcessor',
        'themesProcessor',
        'requestProcessor',
        'tasksProcessor',
        'backupsProcessor',
        'schedulerProcessor',
        'assetsProcessor',
        'twigProcessor',
        'pagesProcessor',
        'debuggerAssetsProcessor',
        'renderProcessor',
    ];

    /** @var array */
    protected $initialized = [];

    /**
     * Reset the Grav instance.
     *
     * @return void
     */
    public static function resetInstance(): void
    {
        if (self::$instance) {
            // @phpstan-ignore-next-line
            self::$instance = null;
        }
    }

    /**
     * Return the Grav instance. Create it if it's not already instanced
     *
     * @param array $values
     * @return Grav
     */
    public static function instance(array $values = [])
    {
        if (null === self::$instance) {
            self::$instance = static::load($values);

            /** @var ClassLoader|null $loader */
            $loader = self::$instance['loader'] ?? null;
            if ($loader) {
                // Load fix for Deferred Twig Extension
                $loader->addPsr4('Phive\\Twig\\Extensions\\Deferred\\', LIB_DIR . 'Phive/Twig/Extensions/Deferred/', true);
            }
        } elseif ($values) {
            $instance = self::$instance;
            foreach ($values as $key => $value) {
                $instance->offsetSet($key, $value);
            }
        }

        return self::$instance;
    }

    /**
     * Get Grav version.
     *
     * @return string
     */
    public function getVersion(): string
    {
        return GRAV_VERSION;
    }

    /**
     * @return bool
     */
    public function isSetup(): bool
    {
        return isset($this->initialized['setup']);
    }

    /**
     * Setup Grav instance using specific environment.
     *
     * @param string|null $environment
     * @return $this
     */
    public function setup(?string $environment = null)
    {
        if (isset($this->initialized['setup'])) {
            return $this;
        }

        $this->initialized['setup'] = true;

        // Force environment if passed to the method.
        if ($environment) {
            Setup::$environment = $environment;
        }

        // Initialize setup and streams.
        $this['setup'];
        $this['streams'];

        return $this;
    }

    /**
     * Initialize CLI environment.
     *
     * Call after `$grav->setup($environment)`
     *
     * - Load configuration
     * - Initialize logger
     * - Disable debugger
     * - Set timezone, locale
     * - Load plugins (call PluginsLoadedEvent)
     * - Set Pages and Users type to be used in the site
     *
     * This method WILL NOT initialize assets, twig or pages.
     *
     * @return $this
     */
    public function initializeCli()
    {
        InitializeProcessor::initializeCli($this);

        return $this;
    }

    /**
     * Process a request
     *
     * @return void
     */
    public function process(): void
    {
        if (isset($this->initialized['process'])) {
            return;
        }

        // Initialize Grav if needed.
        $this->setup();

        $this->initialized['process'] = true;

        $container = new Container(
            [
                'multipartRequestSupport' => fn() => new MultipartRequestSupport(),
                'initializeProcessor' => fn() => new InitializeProcessor($this),
                'backupsProcessor' => fn() => new BackupsProcessor($this),
                'pluginsProcessor' => fn() => new PluginsProcessor($this),
                'themesProcessor' => fn() => new ThemesProcessor($this),
                'schedulerProcessor' => fn() => new SchedulerProcessor($this),
                'requestProcessor' => fn() => new RequestProcessor($this),
                'tasksProcessor' => fn() => new TasksProcessor($this),
                'assetsProcessor' => fn() => new AssetsProcessor($this),
                'twigProcessor' => fn() => new TwigProcessor($this),
                'pagesProcessor' => fn() => new PagesProcessor($this),
                'debuggerAssetsProcessor' => fn() => new DebuggerAssetsProcessor($this),
                'renderProcessor' => fn() => new RenderProcessor($this),
            ]
        );

        $default = static fn() => new Response(404, ['Expires' => 0, 'Cache-Control' => 'no-store, max-age=0'], 'Not Found');

        $collection = new RequestHandler($this->middleware, $default, $container);

        $response = $collection->handle($this['request']);
        $body = $response->getBody();

        /** @var Messages $messages */
        $messages = $this['messages'];

        // Prevent caching if session messages were displayed in the page.
        $noCache = $messages->isCleared();
        if ($noCache) {
            $response = $response->withHeader('Cache-Control', 'no-store, max-age=0');
        }

        // Handle ETag and If-None-Match headers.
        if ($response->getHeaderLine('ETag') === '1') {
            // xxh128 gives the same 32 hex characters as md5 at a fraction of the cost on a large page.
            $etag = hash('xxh128', (string)$body);
            $response = $response->withHeader('ETag', '"' . $etag . '"');

            $search = trim((string) $this['request']->getHeaderLine('If-None-Match'), '"');
            if ($noCache === false && $search === $etag) {
                $response = $response->withStatus(304);
                $body = '';
            }
        }

        // Echo page content.
        $this->header($response);

        // A large or unknown-length body (e.g. a file-backed download stream) is
        // streamed straight to the client rather than echoed, which would buffer
        // the whole thing in memory. Streaming flushes output and thus commits the
        // headers, so the debugger and shutdown handler — both of which would try
        // to write to an already-sent response — are skipped.
        if ($this->streamResponseBody($body)) {
            exit();
        }

        echo $body;

        $this['debugger']->render();

        // Response object can turn off all shutdown processing. This can be used for example to speed up AJAX responses.
        // Note that using this feature will also turn off response compression.
        $this->registerShutdown($response);
    }

    /**
     * Register shutdown() to run once PHP finishes this request, unless the
     * response asked to skip it with the `Grav-Internal-SkipShutdown` header.
     *
     * Registered rather than called, so it runs after exit() as well as after
     * a normal render: close() and redirect() end with exit(), and the work
     * that plugins hang on onShutdown (sending queued mail, warming a cache)
     * has to run after those responses too, not only after a rendered page.
     *
     * @param ResponseInterface $response
     * @return bool whether shutdown() will run for this request
     */
    protected function registerShutdown(ResponseInterface $response): bool
    {
        if ($response->getHeaderLine('Grav-Internal-SkipShutdown') === '1') {
            return false;
        }
        if ($this->shutdownRegistered) {
            return true;
        }

        $this->shutdownRegistered = true;
        register_shutdown_function([$this, 'shutdown']);

        return true;
    }

    /**
     * Clean any output buffers. Useful when exiting from the application.
     *
     * Please use $grav->close() and $grav->redirect() instead of calling this one!
     *
     * @return void
     */
    public function cleanOutputBuffers(): void
    {
        // Make sure nothing extra gets written to the response.
        self::endOutputBuffers(false);
        // Work around PHP bug #8218 (8.0.17 & 8.1.4).
        header_remove('Content-Encoding');
    }

    /**
     * End the output buffers PHP allows to be ended, sending or discarding what
     * they hold.
     *
     * Stops at the first buffer that cannot be removed. PHP's own
     * zlib.output_compression handler becomes one of those once it has written
     * its first compressed chunk, and ending it anyway raises a notice that the
     * error handler turns into an exception (#4294). Inside shutdown() that
     * skipped onShutdown and appended an error page to the gzip stream. Nothing
     * below such a buffer can be reached and PHP finishes it at the end of the
     * request, so its pending output is only flushed or discarded in place, and
     * only where the buffer allows it.
     *
     * @param bool $flush True to send the buffered output, false to discard it.
     * @return void
     */
    private static function endOutputBuffers(bool $flush): void
    {
        while (ob_get_level() > 0) {
            $flags = ob_get_status()['flags'] ?? 0;
            if (!($flags & PHP_OUTPUT_HANDLER_REMOVABLE)) {
                if ($flush && ($flags & PHP_OUTPUT_HANDLER_FLUSHABLE)) {
                    ob_flush();
                } elseif (!$flush && ($flags & PHP_OUTPUT_HANDLER_CLEANABLE)) {
                    ob_clean();
                }

                return;
            }

            // Never spin on a buffer PHP refused to end.
            if (!($flush ? ob_end_flush() : ob_end_clean())) {
                return;
            }
        }
    }

    /**
     * Whether a response body should be streamed to the client in chunks rather
     * than echoed as one string.
     *
     * A plain string body (the common Twig-rendered page) reports a small, known
     * size and is echoed as before. A body that is large or of unknown length —
     * typically a file resource wrapped in a stream, as used for backup and media
     * downloads — is streamed so it never has to be materialised in memory.
     *
     * @param string|StreamInterface $body
     * @return bool
     */
    protected function isStreamedBody($body): bool
    {
        if (!$body instanceof StreamInterface || !$body->isReadable()) {
            return false;
        }

        $size = $body->getSize();

        // Unknown length (e.g. a pipe) or larger than the buffer threshold.
        return $size === null || $size > static::STREAM_BODY_THRESHOLD;
    }

    /**
     * Stream a large response body straight to the client in chunks.
     *
     * Casting a big stream to a string (via `echo`) buffers the whole payload in
     * memory, which fails with an HTTP 500 once it exceeds `memory_limit` — the
     * failure mode behind malformed backup downloads. Reading and flushing in
     * fixed-size chunks keeps memory flat regardless of file size.
     *
     * Returns false without consuming the body when it is small enough to echo
     * normally, so the caller falls back to the existing buffered path.
     *
     * @param string|StreamInterface $body
     * @return bool True when the body was streamed (output already sent).
     */
    protected function streamResponseBody($body): bool
    {
        if (!$this->isStreamedBody($body)) {
            return false;
        }

        /** @var StreamInterface $body */

        // Drop any output buffering so bytes go straight to the socket instead of
        // piling up in a buffer that would hit the same memory ceiling.
        $this->cleanOutputBuffers();

        if ($body->isSeekable()) {
            $body->rewind();
        }

        while (!$body->eof()) {
            if (connection_status() !== CONNECTION_NORMAL) {
                break;
            }
            echo $body->read(static::STREAM_BODY_CHUNK);
            flush();
        }

        $body->close();

        return true;
    }

    /**
     * Terminates Grav request with a response.
     *
     * Please use this method instead of calling `die();` or `exit();`. Note that you need to create a response object.
     *
     * @param ResponseInterface $response
     * @return never-return
     */
    public function close(ResponseInterface $response): void
    {
        // In CLI, throw instead of exit() so commands can report the problem
        // rather than terminate silently. A plugin calling redirect()/close()
        // during a console command (e.g. inside onPluginsInitialized) would
        // otherwise kill the process with no error visible to the user.
        if (\PHP_SAPI === 'cli') {
            $location = $response->getHeaderLine('Location');
            $detail = $location !== '' ? " (redirect to {$location})" : '';
            throw new RuntimeException("Grav::close() called in CLI context{$detail}");
        }

        $this->cleanOutputBuffers();

        // Close the session.
        if (isset($this['session'])) {
            $this['session']->close();
        }

        /** @var ServerRequestInterface $request */
        $request = $this['request'];

        /** @var Debugger $debugger */
        $debugger = $this['debugger'];
        $response = $debugger->logRequest($request, $response);

        $body = $response->getBody();

        /** @var Messages $messages */
        $messages = $this['messages'];

        // Prevent caching if session messages were displayed in the page.
        $noCache = $messages->isCleared();
        if ($noCache) {
            $response = $response->withHeader('Cache-Control', 'no-store, max-age=0');
        }

        // Handle ETag and If-None-Match headers. A streamed body (large or of
        // unknown length) is left untouched: hashing it here would read the whole
        // thing into memory, defeating the point of streaming, and file downloads
        // don't need a content ETag.
        if ($response->getHeaderLine('ETag') === '1' && !$this->isStreamedBody($body)) {
            // xxh128 gives the same 32 hex characters as md5 at a fraction of the cost on a large page.
            $etag = hash('xxh128', (string)$body);
            $response = $response->withHeader('ETag', '"' . $etag . '"');

            $search = trim((string) $this['request']->getHeaderLine('If-None-Match'), '"');
            if ($noCache === false && $search === $etag) {
                $response = $response->withStatus(304);
                $body = '';
            }
        }

        // A redirect or an early close is still a finished request: the slow
        // work plugins queue for onShutdown runs after it exactly as it does
        // after a rendered page.
        $shutdown = $this->registerShutdown($response);

        // Echo page content.
        $this->header($response);
        if ($this->streamResponseBody($body)) {
            // Streaming committed the headers, so shutdown() will skip the
            // header-based connection close and only run the event.
            exit();
        }

        if ($shutdown) {
            // Hold the body in a buffer, the way a rendered page is held, so
            // that on a host without fastcgi_finish_request shutdown() can still
            // frame the response with Content-Length and Connection: close
            // before the slow work starts. Without this the client would wait
            // for the whole of onShutdown before its redirect or JSON answer
            // was complete.
            ob_start();
        }
        echo $body;

        exit();
    }

    /**
     * @param ResponseInterface $response
     * @return never-return
     * @deprecated 1.7 Use $grav->close() instead.
     */
    public function exit(ResponseInterface $response): void
    {
        $this->close($response);
    }

    /**
     * Terminates Grav request and redirects browser to another location.
     *
     * Please use this method instead of calling `header("Location: {$url}", true, 302); exit();`.
     *
     * @param Route|string $route Internal route.
     * @param int|null $code  Redirection code (30x)
     * @return never-return
     */
    public function redirect($route, $code = null): void
    {
        $response = $this->getRedirectResponse($route, $code);

        $this->close($response);
    }

    /**
     * Returns redirect response object from Grav.
     *
     * @param Route|string $route Internal route.
     * @param int|null $code  Redirection code (30x)
     * @return ResponseInterface
     */
    public function getRedirectResponse($route, $code = null): ResponseInterface
    {
        /** @var Uri $uri */
        $uri = $this['uri'];

        if (is_string($route)) {
            // Clean route for redirect
            $route = preg_replace("#^\/[\\\/]+\/#", '/', $route);

            // Classify the configured destination before removing its redirect code.
            $external = $uri::isExternal($route);

            if (null === $code) {
                // Check for redirect code in the route: e.g. /new/[301], /new[301]/route, /new[301].html, /new[301]?a=1&b=2 or /new[301].html?a=1
                $regex = '/.*(\[(30[1-7])\])(.\w+|\/.*?)?([?#].*)?$/';
                preg_match($regex, (string) $route, $matches);
                if ($matches) {
                    $route = str_replace($matches[1], '', $matches[0]);
                    $code = $matches[2];
                }
            }

            if ($external) {
                $url = $route;
            } else {
                $url = rtrim($uri->rootUrl(), '/') . '/';

                if ($this['config']->get('system.pages.redirect_trailing_slash', true)) {
                    $url .= trim((string) $route, '/'); // Remove trailing slash
                } else {
                    $url .= ltrim((string) $route, '/'); // Support trailing slash default routes
                }

                // A request for `/section.md` that Grav redirects (to a first
                // child, a default route, a language prefix) should land on
                // Markdown too, or the agent following it silently gets HTML.
                if ($uri->extension() === MarkdownOutput::FORMAT
                    && MarkdownOutput::enabled()
                    && !preg_match('/[?#]/', $url)
                    && !Utils::pathinfo($url, PATHINFO_EXTENSION)) {
                    // The site root has nothing to carry an extension; it is `/index.md`.
                    if (trim((string) parse_url($url, PHP_URL_PATH), '/') === '') {
                        $url = rtrim($url, '/') . '/index';
                    }
                    $url .= '.' . MarkdownOutput::FORMAT;
                }
            }
        } elseif ($route instanceof Route) {
            $url = $route->toString(true);
        } else {
            throw new InvalidArgumentException('Bad $route');
        }

        if ($code < 300 || $code > 399) {
            $code = null;
        }

        if ($code === null) {
            $code = $this['config']->get('system.pages.redirect_default_code', 302);
        }

        if ($uri->extension() === 'json') {
            return new Response(200, ['Content-Type' => 'application/json'], json_encode(['code' => $code, 'redirect' => $url], JSON_THROW_ON_ERROR));
        }

        return new Response($code, ['Location' => $url]);
    }

    /**
     * Redirect browser to another location taking language into account (preferred)
     *
     * @param string $route Internal route.
     * @param int    $code  Redirection code (30x)
     * @return void
     */
    public function redirectLangSafe($route, $code = null): void
    {
        if (!$this['uri']->isExternal($route)) {
            $this->redirect($this['pages']->route($route), $code);
        } else {
            $this->redirect($route, $code);
        }
    }

    /**
     * Set response header.
     *
     * @param ResponseInterface|null $response
     * @return void
     */
    public function header(?ResponseInterface $response = null): void
    {
        if (null === $response) {
            /** @var PageInterface $page */
            $page = $this['page'];
            $response = new Response($page->httpResponseCode(), $page->httpHeaders(), '');
        }

        header("HTTP/{$response->getProtocolVersion()} {$response->getStatusCode()} {$response->getReasonPhrase()}");
        foreach ($response->getHeaders() as $key => $values) {
            // Skip internal Grav headers.
            if (str_starts_with((string) $key, 'Grav-Internal-')) {
                continue;
            }
            foreach ($values as $i => $value) {
                header($key . ': ' . $value, $i === 0);
            }
        }
    }

    /**
     * Set the system locale based on the language and configuration
     *
     * @return void
     */
    public function setLocale(): void
    {
        // Initialize Locale if set and configured.
        if ($this['language']->enabled() && $this['config']->get('system.languages.override_locale')) {
            $language = $this['language']->getLanguage();
            setlocale(LC_ALL, strlen((string) $language) < 3 ? ($language . '_' . strtoupper((string) $language)) : $language);
        } elseif ($this['config']->get('system.default_locale')) {
            setlocale(LC_ALL, $this['config']->get('system.default_locale'));
        }
    }

    /**
     * @param object $event
     * @return object
     */
    public function dispatchEvent($event)
    {
        /** @var EventDispatcherInterface $events */
        $events = $this['events'];
        $eventName = $event::class;

        $timestamp = microtime(true);
        $event = $events->dispatch($event);

        /** @var Debugger $debugger */
        $debugger = $this['debugger'];
        $debugger->addEvent($eventName, $event, $events, $timestamp);

        return $event;
    }

    /**
     * Fires an event with optional parameters.
     *
     * @param  string $eventName
     * @param  Event|null $event
     * @return Event
     */
    public function fireEvent($eventName, ?Event $event = null)
    {
        /** @var EventDispatcherInterface $events */
        $events = $this['events'];
        if (null === $event) {
            $event = new Event();
        }

        $timestamp = microtime(true);
        $events->dispatch($event, $eventName);

        /** @var Debugger $debugger */
        $debugger = $this['debugger'];
        if ($debugger->enabled()) {
            $debugger->addEvent($eventName, $event, $events, $timestamp);
        }

        return $event;
    }

    /**
     * Set the final content length for the page and flush the buffer
     *
     * @return void
     */
    public function shutdown(): void
    {
        // Prevent user abort allowing onShutdown event to run without interruptions.
        if (function_exists('ignore_user_abort')) {
            @ignore_user_abort(true);
        }

        // Close the session allowing new requests to be handled. A failure here must
        // not cost every plugin its onShutdown work, so it is logged and skipped.
        if (isset($this['session'])) {
            try {
                $this['session']->close();
            } catch (\Throwable $e) {
                $this['log']->error('Session close failed during shutdown: ' . $e->getMessage());
            }
        }

        /** @var Config $config */
        $config = $this['config'];
        if ($config->get('system.debugger.shutdown.close_connection', true)) {
            // Flush the response and close the connection to allow time consuming tasks to be performed without leaving
            // the connection to the client open. This will make page loads to feel much faster.

            // FastCGI allows us to flush all response data to the client and finish the request.
            $success = function_exists('fastcgi_finish_request') ? @fastcgi_finish_request() : false;
            if (!$success && !headers_sent()) {
                // Unfortunately without FastCGI there is no way to force close the connection.
                // We need to ask browser to close the connection for us.

                // Check if external compression is active (e.g., zlib.output_compression in php.ini).
                if (!ini_get('zlib.output_compression')) {
                    // We can only send an accurate Content-Length (and thus let the client
                    // close the connection early) when we are sure the webserver will not
                    // recompress the body underneath us.
                    $canSetContentLength = true;

                    if ($config->get('system.cache.gzip') || $config->get('system.cache.allow_webserver_gzip')) {
                        // Let web server handle compression.
                        header('Content-Encoding: identity');
                    } elseif (function_exists('apache_setenv')) {
                        // Without gzip we have no other choice than to prevent server from compressing the output.
                        // This action turns off mod_deflate which would prevent us from closing the connection.
                        @apache_setenv('no-gzip', '1');
                    } else {
                        // We cannot reliably stop the webserver from compressing here. Previously we emitted
                        // `Content-Encoding: none` to trick most servers into skipping compression, but `none`
                        // is not a valid content-coding and stricter HTTP clients (e.g. Java's
                        // HttpsURLConnection) reject the whole response. Rather than send a spec-violating
                        // header, skip the Content-Length/early-close optimization for this fallback and let
                        // the response close cleanly via `Connection: close` instead (#2619).
                        $canSetContentLength = false;
                    }

                    if ($canSetContentLength && ob_get_level() > 0) {
                        // Get length and close the connection (only when not using compression).
                        header('Content-Length: ' . ob_get_length());
                    }
                }

                header('Connection: close');

                // close() has already emptied every buffer before echoing, so
                // there may be nothing left to end here.
                self::endOutputBuffers(true);
                flush();
            } elseif (!$success) {
                // Headers are out already (close() echoed the body, or the body
                // was streamed), so the connection cannot be closed early. Push
                // whatever is buffered so the client at least has the response.
                self::endOutputBuffers(true);
                flush();
            }
        }

        // Run any time consuming tasks.
        $this->fireEvent('onShutdown');

        // Compile the configuration and language caches written by this request into OPcache
        // now that the response is out.
        CompiledBase::precompilePending();
    }

    /**
     * Magic Catch All Function
     *
     * Used to call closures.
     *
     * Source: http://stackoverflow.com/questions/419804/closures-as-class-members
     *
     * @param string $method
     * @param array $args
     * @return mixed|null
     */
    #[\ReturnTypeWillChange]
    public function __call($method, $args)
    {
        $closure = $this->{$method} ?? null;

        return is_callable($closure) ? $closure(...$args) : null;
    }

    /**
     * Measure how long it takes to do an action.
     *
     * @param string $timerId
     * @param string $timerTitle
     * @param callable $callback
     * @return mixed   Returns value returned by the callable.
     */
    public function measureTime(string $timerId, string $timerTitle, callable $callback)
    {
        $debugger = $this['debugger'];
        $debugger->startTimer($timerId, $timerTitle);
        $result = $callback();
        $debugger->stopTimer($timerId);

        return $result;
    }

    /**
     * Initialize and return a Grav instance
     *
     * @param  array $values
     * @return static
     */
    protected static function load(array $values)
    {
        header_register_callback([Utils::class, 'enforcePrivateCacheControl']);

        $container = new static($values);

        $container['debugger'] = new Debugger();
        $container['grav'] = function (Container $container) {
            user_error('Calling $grav[\'grav\'] or {{ grav.grav }} is deprecated since Grav 1.6, just use $grav or {{ grav }}', E_USER_DEPRECATED);

            return $container;
        };

        $container->registerServices();

        return $container;
    }

    /**
     * Register all services
     * Services are defined in the diMap. They can either only the class
     * of a Service Provider or a pair of serviceKey => serviceClass that
     * gets directly mapped into the container.
     *
     * @return void
     */
    protected function registerServices(): void
    {
        foreach (self::$diMap as $serviceKey => $serviceClass) {
            if (is_int($serviceKey)) {
                $this->register(new $serviceClass);
            } else {
                $this[$serviceKey] = fn($c) => new $serviceClass($c);
            }
        }
    }

    /**
     * This attempts to find media, other files, and download them
     *
     * @param string $path
     * @return PageInterface|false
     */
    public function fallbackUrl($path)
    {
        $path_parts = Utils::pathinfo($path);
        if (!is_array($path_parts)) {
            return false;
        }

        /** @var Uri $uri */
        $uri = $this['uri'];

        /** @var Config $config */
        $config = $this['config'];

        /** @var Pages $pages */
        $pages = $this['pages'];
        $page = $pages->find($path_parts['dirname'], true);

        $uri_extension = strtolower($uri->extension() ?? '');
        $fallback_types = $config->get('system.media.allowed_fallback_types');
        $supported_types = $config->get('media.types');

        // Decode the path basename once; encoded punctuation belongs to the filename.
        $media_file = rawurldecode((string) $uri->basename());

        $event = new Event([
            'uri' => $uri,
            'page' => &$page,
            'filename' => &$media_file,
            'extension' => $uri_extension,
            'allowed_fallback_types' => &$fallback_types,
            'media_types' => &$supported_types
        ]);

        $this->fireEvent('onPageFallBackUrl', $event);

        // Check whitelist first, then ensure extension is a valid media type
        if (!empty($fallback_types) && !in_array($uri_extension, $fallback_types, true)) {
            return false;
        }
        if (!array_key_exists($uri_extension, $supported_types)) {
            return false;
        }

        if ($page) {
            $media = $page->media()->all();

            // if this is a media object, try actions first
            if (isset($media[$media_file])) {
                /** @var Medium $medium */
                $medium = $media[$media_file];

                // URL-based media actions (e.g. `image.jpg?resize=600,400`) let an
                // unauthenticated visitor drive image transforms straight from the
                // query string. They are opt-in and disabled by default: the normal
                // resize path is Twig/Markdown media methods, which run with
                // developer-controlled arguments and are unaffected by this toggle.
                if ($config->get('system.images.url_actions', false)) {
                    $max_pixels = (int) $config->get('system.images.max_pixels', 25000000);
                    // Only raster images allocate a canvas. Track their size
                    // through the chain of actions, so each one is measured
                    // against what the previous ones left.
                    $raster = $medium instanceof ImageMedium;
                    $size = $raster ? ImageFile::decodedSize((string) $medium->get('filepath')) : null;
                    $width = (int) ($size[0] ?? 0);
                    $height = (int) ($size[1] ?? 0);
                    foreach ($uri->query(null, true) as $action => $params) {
                        if (in_array($action, ImageMedium::$magic_actions, true)) {
                            $args = explode(',', (string) $params);
                            // Reject actions whose canvas is above the
                            // total-pixel ceiling. The GD/Imagick buffer is allocated
                            // as width*height*4 bytes outside PHP's memory_limit, so
                            // an unbounded request exhausts RAM. The canvas is
                            // measured, not the query numbers: `forceResize=46000`
                            // and `zoomCrop=46000,1` both allocate 46000x46000, and
                            // `gaussianBlur=14` 8192x8192.
                            if ($max_pixels > 0 && $raster) {
                                $canvas = ImageMedium::urlActionCanvas($action, $args, $width, $height);
                                if ($canvas === null || $canvas[0] > $max_pixels) {
                                    return false;
                                }
                                $width = (int) $canvas[1];
                                $height = (int) $canvas[2];
                            }
                            call_user_func_array([&$medium, $action], $args);
                        }
                    }

                    // That refuses what it can before the source is decoded, but
                    // it only predicts the size each action starts from: the Exif
                    // orientation fix queued ahead of the actions, or a resize
                    // that declines to enlarge, leaves another size than it
                    // expects. So every action is measured again as it runs, on
                    // the image as it really is by then.
                    if ($max_pixels > 0 && $raster) {
                        $medium->maxPixels($max_pixels);
                    }
                }

                try {
                    $file = $medium->path();
                } catch (RangeException) {
                    return false;
                }

                Utils::download($file, false);
            }

            // unsupported media type, try to download it...
            if ($uri_extension) {
                $extension = $uri_extension;
            } elseif (isset($path_parts['extension'])) {
                $extension = $path_parts['extension'];
            } else {
                $extension = null;
            }

            if ($extension) {
                $download = true;
                if (in_array(ltrim((string) $extension, '.'), $config->get('system.media.unsupported_inline_types', []), true)) {
                    $download = false;
                }
                // The basename is still percent-encoded, so decode it the way the
                // media lookup above does, or a file that is not in the media
                // collection (a retina `@2x` file) 404s when its name has a space
                // or a non-ASCII character. Decoding can yield a `/` from `%2F`,
                // so anything that is not a bare file name is refused.
                // getgrav/grav#4332.
                $filename = rawurldecode((string) $uri->basename());
                if ($filename !== '' && strpbrk($filename, "/\\\0") === false) {
                    Utils::download($page->path() . DIRECTORY_SEPARATOR . $filename, $download);
                }
            }
        }

        // Nothing found
        return false;
    }
}
