<?php

namespace Drupal\dynamic_page_cache\EventSubscriber;

use Drupal\Core\Cache\Cache;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Cache\CacheableResponseInterface;
use Drupal\Core\PageCache\RequestPolicyInterface;
use Drupal\Core\PageCache\ResponsePolicyInterface;
use Drupal\Core\Render\RenderCacheInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;

/**
 * Returns cached responses as early and avoiding as much work as possible.
 *
 * Dynamic Page Cache is able to cache so much because it utilizes cache
 * contexts: the cache contexts that are present capture the variations of every
 * component of the page. That, combined with the fact that cacheability
 * metadata is bubbled, means that the cache contexts at the page level
 * represent the complete set of contexts that the page varies by.
 *
 * The reason Dynamic Page Cache is implemented as two event subscribers (a late
 * REQUEST subscriber immediately after routing for cache hits, and an early
 * RESPONSE subscriber for cache misses) is because many cache contexts can only
 * be evaluated after routing. (Examples: 'user', 'user.permissions', 'route' …)
 * Consequently, it is impossible to implement Dynamic Page Cache as a kernel
 * middleware that simply caches per URL.
 *
 * @see \Drupal\Core\Render\MainContent\HtmlRenderer
 * @see \Drupal\Core\Cache\CacheableResponseInterface
 */
class DynamicPageCacheSubscriber implements EventSubscriberInterface {

  /**
   * Name of Dynamic Page Cache's response header.
   */
  const HEADER = 'X-Drupal-Dynamic-Cache';

  /**
   * A request policy rule determining the cacheability of a response.
   *
   * @var \Drupal\Core\PageCache\RequestPolicyInterface
   */
  protected $requestPolicy;

  /**
   * A response policy rule determining the cacheability of the response.
   *
   * @var \Drupal\Core\PageCache\ResponsePolicyInterface
   */
  protected $responsePolicy;

  /**
   * The render cache.
   *
   * @var \Drupal\Core\Render\RenderCacheInterface
   */
  protected $renderCache;

  /**
   * The renderer configuration array.
   *
   * @var array
   */
  protected $rendererConfig;

  /**
   * Dynamic Page Cache's redirect render array.
   *
   * @var array
   */
  protected $dynamicPageCacheRedirectRenderArray = [
    '#cache' => [
      'keys' => ['response'],
      'contexts' => [
        'route',
        // Some routes' controllers rely on the request format (they don't have
        // a separate route for each request format). Additionally, a controller
        // may be returning a domain object that a KernelEvents::VIEW subscriber
        // must turn into an actual response, but perhaps a format is being
        // requested that the subscriber does not support.
        // @see \Drupal\Core\EventSubscriber\RenderArrayNonHtmlSubscriber::onResponse()
        'request_format',
      ],
      'bin' => 'dynamic_page_cache',
    ],
  ];

  /**
   * Internal cache of request policy results.
   *
   * @var \SplObjectStorage
   */
  protected $requestPolicyResults;

  /**
   * Constructs a new DynamicPageCacheSubscriber object.
   *
   * @param \Drupal\Core\PageCache\RequestPolicyInterface $request_policy
   *   A policy rule determining the cacheability of a request.
   * @param \Drupal\Core\PageCache\ResponsePolicyInterface $response_policy
   *   A policy rule determining the cacheability of the response.
   * @param \Drupal\Core\Render\RenderCacheInterface $render_cache
   *   The render cache.
   * @param array $renderer_config
   *   The renderer configuration array.
   */
  public function __construct(RequestPolicyInterface $request_policy, ResponsePolicyInterface $response_policy, RenderCacheInterface $render_cache, array $renderer_config) {
    $this->requestPolicy = $request_policy;
    $this->responsePolicy = $response_policy;
    $this->renderCache = $render_cache;
    $this->rendererConfig = $renderer_config;
    $this->requestPolicyResults = new \SplObjectStorage();
  }

  /**
   * Sets a response in case of a Dynamic Page Cache hit.
   *
   * @param \Symfony\Component\HttpKernel\Event\RequestEvent $event
   *   The event to process.
   */
  public function onRequest(RequestEvent $event) {
    // Don't cache the response if the Dynamic Page Cache request policies are
    // not met. Store the result in a static keyed by current request, so that
    // onResponse() does not have to redo the request policy check.
    $request = $event->getRequest();
    $request_policy_result = $this->requestPolicy->check($request);
    $this->requestPolicyResults[$request] = $request_policy_result;
    if ($request_policy_result === RequestPolicyInterface::DENY) {
      return;
    }

    // Sets the response for the current route, if cached.
    $cached = $this->renderCache->get($this->dynamicPageCacheRedirectRenderArray);
    if ($cached) {
      $response = $this->renderArrayToResponse($cached);
      $response->headers->set(self::HEADER, 'HIT');
      $event->setResponse($response);
    }
  }

  /**
   * Stores a response in case of a Dynamic Page Cache miss, if cacheable.
   *
   * @param \Symfony\Component\HttpKernel\Event\ResponseEvent $event
   *   The event to process.
   */
  public function onResponse(ResponseEvent $event) {
    $response = $event->getResponse();

    // Dynamic Page Cache only works with cacheable responses. It does not work
    // with plain Response objects. (Dynamic Page Cache needs to be able to
    // access and modify the cacheability metadata associated with the
    // response.)
    if (!$response instanceof CacheableResponseInterface) {
      return;
    }

    // There's no work left to be done if this is a Dynamic Page Cache hit.
    if ($response->headers->get(self::HEADER) === 'HIT') {
      return;
    }

    // There's no work left to be done if this is an uncacheable response.
    if (!$this->shouldCacheResponse($response)) {
      // The response is uncacheable, mark it as such.
      $response->headers->set(self::HEADER, 'UNCACHEABLE');
      return;
    }

    // Don't cache the response if Dynamic Page Cache's request subscriber did
    // not fire, because that means it is impossible to have a Dynamic Page
    // Cache hit. This can happen when the master request is for example a 403
    // or 404, in which case a subrequest is performed by the router. In that
    // case, it is the subrequest's response that is cached by Dynamic Page
    // Cache, because the routing happens in a request subscriber earlier than
    // Dynamic Page Cache's and immediately sets a response, i.e. the one
    // returned by the subrequest, and thus causes Dynamic Page Cache's request
    // subscriber to not fire for the master request.
    // @see \Drupal\Core\Routing\AccessAwareRouter::checkAccess()
    // @see \Drupal\Core\EventSubscriber\DefaultExceptionHtmlSubscriber::on403()
    $request = $event->getRequest();
    if (!isset($this->requestPolicyResults[$request])) {
      return;
    }

    // Don't cache the response if the Dynamic Page Cache request & response
    // policies are not met.
    // @see onRequest()
    if ($this->requestPolicyResults[$request] === RequestPolicyInterface::DENY || $this->responsePolicy->check($response, $request) === ResponsePolicyInterface::DENY) {
      return;
    }

    // Embed the response object in a render array so that RenderCache is able
    // to cache it, handling cache redirection for us.
    $response_as_render_array = $this->responseToRenderArray($response);
    $this->renderCache->set($response_as_render_array, $this->dynamicPageCacheRedirectRenderArray);

    // The response was generated, mark the response as a cache miss. The next
    // time, it will be a cache hit.
    $response->headers->set(self::HEADER, 'MISS');
  }

  /**
   * Whether the given response should be cached by Dynamic Page Cache.
   *
   * We consider any response that has cacheability metadata meeting the auto-
   * placeholdering conditions to be uncacheable. Because those conditions
   * indicate poor cacheability, and if it doesn't make sense to cache parts of
   * a page, then neither does it make sense to cache an entire page.
   *
   * But note that auto-placeholdering avoids such cacheability metadata ever
   * bubbling to the response level: while rendering, the Renderer checks every
   * subtree to see if meets the auto-placeholdering conditions. If it does, it
   * is automatically placeholdered, and consequently the cacheability metadata
   * of the placeholdered content does not bubble up to the response level.
   *
   * @param \Drupal\Core\Cache\CacheableResponseInterface $response
   *   The response whose cacheability to analyze.
   *
   * @return bool
   *   Whether the given response should be cached.
   *
   * @see \Drupal\Core\Render\Renderer::shouldAutomaticallyPlaceholder()
   */
  protected function shouldCacheResponse(CacheableResponseInterface $response) {
    $conditions = $this->rendererConfig['auto_placeholder_conditions'];

    $cacheability = $response->getCacheableMetadata();

    // Response's max-age is at or below the configured threshold.
    if ($cacheability->getCacheMaxAge() !== Cache::PERMANENT && $cacheability->getCacheMaxAge() <= $conditions['max-age']) {
      return FALSE;
    }

    // Response has a high-cardinality cache context.
    if (array_intersect($cacheability->getCacheContexts(), $conditions['contexts'])) {
      return FALSE;
    }

    // Response has a high-invalidation frequency cache tag.
    if (array_intersect($cacheability->getCacheTags(), $conditions['tags'])) {
      return FALSE;
    }

    return TRUE;
  }

  /**
   * Embeds a Response object in a render array so that RenderCache can cache it.
   *
   * @param \Drupal\Core\Cache\CacheableResponseInterface $response
   *   A cacheable response.
   *
   * @return array
   *   A render array that embeds the given cacheable response object, with the
   *   cacheability metadata of the response object present in the #cache
   *   property of the render array.
   *
   * @see renderArrayToResponse()
   *
   * @todo Refactor/remove once https://www.drupal.org/node/2551419 lands.
   */
  protected function responseToRenderArray(CacheableResponseInterface $response) {
    $response_as_render_array = $this->dynamicPageCacheRedirectRenderArray + [
      // The data we actually care about.
      '#response' => $response,
      // Tell RenderCache to cache the #response property: the data we actually
      // care about.
      '#cache_properties' => ['#response'],
      // These exist only to fulfill the requirements of the RenderCache, which
      // is designed to work with render arrays only. We don't care about these.
      '#markup' => '',
      '#attached' => '',
    ];

    // Merge the response's cacheability metadata, so that RenderCache can take
    // care of cache redirects for us.
    CacheableMetadata::createFromObject($response->getCacheableMetadata())
      ->merge(CacheableMetadata::createFromRenderArray($response_as_render_array))
      ->applyTo($response_as_render_array);

    return $response_as_render_array;
  }

  /**
   * Gets the embedded Response object in a render array.
   *
   * @param array $render_array
   *   A render array with a #response property.
   *
   * @return \Drupal\Core\Cache\CacheableResponseInterface
   *   The cacheable response object.
   *
   * @see responseToRenderArray()
   */
  protected function renderArrayToResponse(array $render_array) {
    return $render_array['#response'];
  }

  /**
   * {@inheritdoc}
   */
  public static function getSubscribedEvents(): array {
    $events = [];

    // Run after AuthenticationSubscriber (necessary for the 'user' cache
    // context; priority 300) and MaintenanceModeSubscriber (Dynamic Page Cache
    // should not be polluted by maintenance mode-specific behavior; priority
    // 30), but before ContentControllerSubscriber (updates _controller, but
    // that is a no-op when Dynamic Page Cache runs; priority 25).
    $events[KernelEvents::REQUEST][] = ['onRequest', 27];

    // Run before HtmlResponseSubscriber::onRespond(), which has priority 0.
    $events[KernelEvents::RESPONSE][] = ['onResponse', 100];

    return $events;
  }

}
