<?php

namespace Google\Web_Stories_Dependencies\AmpProject\Optimizer\Transformer;

use Google\Web_Stories_Dependencies\AmpProject\Amp;
use Google\Web_Stories_Dependencies\AmpProject\Html\Attribute;
use Google\Web_Stories_Dependencies\AmpProject\Dom\Document;
use Google\Web_Stories_Dependencies\AmpProject\Dom\Element;
use Google\Web_Stories_Dependencies\AmpProject\Dom\NodeWalker;
use Google\Web_Stories_Dependencies\AmpProject\Exception\FailedToParseUrl;
use Google\Web_Stories_Dependencies\AmpProject\Extension;
use Google\Web_Stories_Dependencies\AmpProject\Layout;
use Google\Web_Stories_Dependencies\AmpProject\Optimizer\Configuration\OptimizeHeroImagesConfiguration;
use Google\Web_Stories_Dependencies\AmpProject\Optimizer\Error;
use Google\Web_Stories_Dependencies\AmpProject\Optimizer\ErrorCollection;
use Google\Web_Stories_Dependencies\AmpProject\Optimizer\HeroImage;
use Google\Web_Stories_Dependencies\AmpProject\Optimizer\ImageDimensions;
use Google\Web_Stories_Dependencies\AmpProject\Optimizer\Transformer;
use Google\Web_Stories_Dependencies\AmpProject\Optimizer\TransformerConfiguration;
use Google\Web_Stories_Dependencies\AmpProject\Html\RequestDestination;
use Google\Web_Stories_Dependencies\AmpProject\Html\Tag;
use Google\Web_Stories_Dependencies\AmpProject\Url;
/**
 * OptimizeHeroImages - this transformer optimizes image rendering times for hero images. For hero images it will:
 *
 * 1. Inject a preload hint (if possible)
 * 2. Generate img tag(s) enabling the browser to render the hero image(s) without the AMP runtime being loaded.
 *
 * Hero images are either identified automatically or can be explicitly defined by adding an `data-hero` attribute or
 * `data-hero-candidate` to the element. If `data-hero` is present, then auto-detection will not be used to identify
 * any other hero images; using `data-hero-candidate` allows for images to be identified as heroes without
 * short-circuiting automatic detection for other images. Lastly, if no image has either `data-hero` or
 * `data-hero-candidate`, then the first non-tiny image that occurs before two paragraphs is used as the hero.
 *
 * This transformer supports the following options:
 *
 * * `optimizeHeroImages`: [true|false] - enables or disables hero image optimization. The default is `true`.
 * * `maxHeroImageCount`: [int] - defines the maximum number of hero images per page. The default is `2`.
 *
 * This is ported from the NodeJS optimizer.
 *
 * @version 588f80bb6606e9b1eb175381038c215c459f1462
 * @link    https://github.com/ampproject/amp-toolbox/blob/588f80bb6606e9b1eb175381038c215c459f1462/packages/optimizer/lib/transformers/OptimizeHeroImages.js
 *
 * @package ampproject/amp-toolbox
 */
final class OptimizeHeroImages implements Transformer
{
    /**
     * Class(es) to apply to a serverside-rendered image element.
     *
     * @var string
     */
    const SSR_IMAGE_CLASS = 'i-amphtml-fill-content i-amphtml-replaced-content';
    /**
     * List of attributes to copy onto an SSR'ed image.
     *
     * @var string[]
     */
    const ATTRIBUTES_TO_COPY = [Attribute::ALT, Attribute::ATTRIBUTION, Attribute::REFERRERPOLICY, Attribute::SRC, Attribute::SRCSET, Attribute::SIZES, Attribute::TITLE];
    /**
     * List of attributes to inline onto an SSR'ed image.
     *
     * @var string[]
     */
    const ATTRIBUTES_TO_INLINE = [Attribute::OBJECT_FIT, Attribute::OBJECT_POSITION];
    /**
     * List of AMP elements that are an embed that can have a placeholder.
     *
     * The array has values assigned so that we can do a fast hash lookup on the element name.
     *
     * @var bool[]
     */
    const AMP_EMBEDS = [Extension::AD => \true, Extension::ANIM => \true, Extension::BRIGHTCOVE => \true, Extension::DAILYMOTION => \true, Extension::FACEBOOK => \true, Extension::GFYCAT => \true, Extension::IFRAME => \true, Extension::IMGUR => \true, Extension::INSTAGRAM => \true, Extension::PINTEREST => \true, Extension::REDDIT => \true, Extension::TWITTER => \true, Extension::VIDEO => \true, Extension::VIDEO_IFRAME => \true, Extension::VIMEO => \true, Extension::WISTIA_PLAYER => \true, Extension::YOUTUBE => \true];
    /**
     * XPath query to relatively fetch all noscript > img elements.
     *
     * @var string
     */
    const NOSCRIPT_IMG_XPATH_QUERY = './noscript/img';
    /**
     * XPath query to relatively fetch all noscript > img[fetchpriority="high"] elements.
     *
     * @var string
     */
    const NOSCRIPT_IMG_FETCHPRIORITY_HIGH_XPATH_QUERY = './noscript/img[@fetchpriority="high"]';
    /**
     * Regular expression pattern to extract the URL from a CSS background-image property.
     *
     * @var string
     */
    const CSS_BACKGROUND_IMAGE_URL_REGEX_PATTERN = '/background-image\\s*:\\s*url\\(\\s*(?<url>[^)]*\\s*)/i';
    /**
     * Configuration store to use.
     *
     * @var TransformerConfiguration
     */
    private $configuration;
    /**
     * Reference node to attach preload links to.
     *
     * @var Element|null
     */
    private $preloadReferenceNode;
    /**
     * Inline style backup attribute that stores inline styles that are being moved to <style amp-custom>.
     *
     * An empty string signifies that no inline style backup is available.
     *
     * @var string
     */
    private $inlineStyleBackupAttribute;
    /**
     * Instantiate a PreloadHeroImage object.
     *
     * @param TransformerConfiguration $configuration Configuration store to use.
     */
    public function __construct(TransformerConfiguration $configuration)
    {
        $this->configuration = $configuration;
    }
    /**
     * Apply transformations to the provided DOM document.
     *
     * @param Document        $document DOM document to apply the transformations to.
     * @param ErrorCollection $errors   Collection of errors that are collected during transformation.
     * @return void
     */
    public function transform(Document $document, ErrorCollection $errors)
    {
        if ($this->configuration->get(OptimizeHeroImagesConfiguration::OPTIMIZE_HERO_IMAGES) === \false) {
            return;
        }
        $maxHeroImageCount = $this->configuration->get(OptimizeHeroImagesConfiguration::MAX_HERO_IMAGE_COUNT);
        $this->inlineStyleBackupAttribute = $this->configuration->get(OptimizeHeroImagesConfiguration::INLINE_STYLE_BACKUP_ATTRIBUTE);
        $heroImages = $this->findHeroImages($document, $maxHeroImageCount);
        $heroImageCount = \count($heroImages);
        if ($heroImageCount > $maxHeroImageCount) {
            $errors->add(Error\TooManyHeroImages::whenPastMaximum());
            $heroImageCount = $maxHeroImageCount;
        }
        for ($index = 0; $index < $heroImageCount; $index++) {
            $this->removeLazyLoading($heroImages[$index]);
            $this->generatePreload($heroImages[$index], $document, $errors);
            $this->generateImg($heroImages[$index], $document);
        }
    }
    /**
     * Find the hero images to optimize.
     *
     * @param Document $document          Document to look for hero images in.
     * @param int      $maxHeroImageCount Maximum number of hero images to accept.
     * @return HeroImage[] Array of hero images to optimize.
     */
    private function findHeroImages(Document $document, $maxHeroImageCount)
    {
        $heroImages = [];
        $heroImageCandidates = [];
        $heroImageFallback = null;
        $seenParagraphCount = 0;
        $node = $document->body;
        while ($node !== null) {
            if (!$node instanceof Element || $node->parentNode instanceof Element && $node->parentNode->tagName === Tag::NOSCRIPT) {
                $node = NodeWalker::nextNode($node);
                continue;
            }
            if ($node->tagName === Tag::P) {
                $seenParagraphCount++;
            }
            if ($node->tagName === Extension::IMG && !$node->hasAttribute(Attribute::DATA_HERO)) {
                $highFetchpriorityImage = $document->xpath->query(self::NOSCRIPT_IMG_FETCHPRIORITY_HIGH_XPATH_QUERY, $node)->item(0);
                if ($highFetchpriorityImage instanceof Element) {
                    $node->appendChild($document->createAttribute(Attribute::DATA_HERO));
                }
            }
            $heroImage = $this->detectImageWithAttribute($node, Attribute::DATA_HERO);
            if ($heroImage) {
                $heroImages[] = $heroImage;
            } elseif (\count($heroImageCandidates) < $maxHeroImageCount) {
                $heroImageCandidate = $this->detectImageWithAttribute($node, Attribute::DATA_HERO_CANDIDATE);
                if ($heroImageCandidate) {
                    $heroImageCandidates[] = $heroImageCandidate;
                } elseif ($seenParagraphCount < 2 && !$heroImageFallback) {
                    $heroImageFallback = $this->detectPossibleHeroImageFallbacks($node);
                }
            }
            if (Amp::isTemplate($node)) {
                // Ignore images inside templates.
                $node = NodeWalker::skipNodeAndChildren($node);
            } else {
                $node = NodeWalker::nextNode($node);
            }
        }
        if (\count($heroImages) > 0) {
            return $heroImages;
        }
        // Since there were no hero images, use the hero image candidates instead.
        $heroImages = \array_slice($heroImageCandidates, 0, $maxHeroImageCount);
        if (\count($heroImages) < 1 && $heroImageFallback) {
            $heroImages[] = $heroImageFallback;
        }
        return $heroImages;
    }
    /**
     * Detect a hero image with a specific attribute.
     *
     * This is used for detecting an image marked with data-hero or data-hero-candidate
     *
     * @param Element $element   Element to detect for.
     * @param string  $attribute Attribute to look for.
     * @return HeroImage|null Detected hero image, or null if none detected.
     * @throws FailedToParseUrl Exception when the URL or Base URL is malformed.
     */
    private function detectImageWithAttribute(Element $element, $attribute)
    {
        if (!$element->hasAttribute($attribute)) {
            return null;
        }
        $src = $element->getAttribute(Attribute::SRC);
        if ($element->tagName === Extension::IMG && (new Url($src))->isValidNonDataUrl()) {
            return new HeroImage($src, $element->getAttribute(Attribute::MEDIA), $element->getAttribute(Attribute::SRCSET), $element);
        }
        if ($this->isAmpEmbed($element)) {
            $placeholderImage = $this->getPlaceholderImage($element);
            if (null !== $placeholderImage) {
                return $placeholderImage;
            }
        }
        $cssBackgroundImage = $this->getCssBackgroundImageUrl($element);
        if ((new Url($cssBackgroundImage))->isValidNonDataUrl()) {
            return new HeroImage($cssBackgroundImage, $element->getAttribute(Attribute::MEDIA), '', $element);
        }
        return null;
    }
    /**
     * Detect a possible hero image fallback.
     *
     * The hero image here can come from one of <amp-img>, <amp-video>, <amp-iframe>, <amp-video-iframe>.
     *
     * @param Element $element Element to detect for.
     * @return HeroImage|null Detected hero image fallback, or null if none detected.
     */
    private function detectPossibleHeroImageFallbacks(Element $element)
    {
        if ($element->hasAttribute(Attribute::LAYOUT) && $element->getAttribute(Attribute::LAYOUT) === Layout::NODISPLAY) {
            return null;
        }
        if ($element->tagName === Extension::IMG || $element->tagName === Tag::IMG) {
            return $this->detectPossibleHeroImageFallbackForAmpImg($element);
        }
        if ($element->tagName === Extension::VIDEO) {
            return $this->detectPossibleHeroImageFallbackForPosterImage($element);
        }
        if ($this->isAmpEmbed($element)) {
            return $this->detectPossibleHeroImageFallbackForPlaceholderImage($element);
        }
        return null;
    }
    /**
     * Detect a possible hero image fallback from an <amp-img> element.
     *
     * @param Element $element Element to detect for.
     * @return HeroImage|null Detected hero image fallback, or null if none detected.
     */
    private function detectPossibleHeroImageFallbackForAmpImg(Element $element)
    {
        $src = $element->getAttribute(Attribute::SRC);
        if (empty($src)) {
            return null;
        }
        if (!(new Url($src))->isValidNonDataUrl()) {
            return null;
        }
        if ((new ImageDimensions($element))->isTiny()) {
            return null;
        }
        $srcset = $element->getAttribute(Attribute::SRCSET);
        $media = $element->getAttribute(Attribute::MEDIA);
        return new HeroImage($src, $media, $srcset, $element);
    }
    /**
     * Detect a possible hero image fallback from a video's poster (= placeholder) image.
     *
     * @param Element $element Element to detect for.
     * @return HeroImage|null Detected hero image fallback, or null if none detected.
     */
    private function detectPossibleHeroImageFallbackForPosterImage(Element $element)
    {
        $poster = $element->getAttribute(Attribute::POSTER);
        if (!$poster) {
            return null;
        }
        if (!(new Url($poster))->isValidNonDataUrl()) {
            return null;
        }
        if ((new ImageDimensions($element))->isTiny()) {
            return null;
        }
        $media = $element->getAttribute(Attribute::MEDIA);
        return new HeroImage($poster, $media, '');
    }
    /**
     * Detect a possible hero image fallback from a placeholder image.
     *
     * @param Element $element Element to detect for.
     * @return HeroImage|null Detected hero image fallback, or null if none detected.
     */
    private function detectPossibleHeroImageFallbackForPlaceholderImage(Element $element)
    {
        // The placeholder will be a child node of the element.
        if (!$element->hasChildNodes()) {
            return null;
        }
        // Don't bother if the element is too small.
        if ((new ImageDimensions($element))->isTiny()) {
            return null;
        }
        return $this->getPlaceholderImage($element);
    }
    /**
     * Get the placeholder image for a given element.
     *
     * @param Element $element Element to check the placeholder image for.
     * @return HeroImage|null Placeholder image to use or null if none found.
     */
    private function getPlaceholderImage(Element $element)
    {
        foreach ($element->childNodes as $childNode) {
            if (!$childNode instanceof Element || !$childNode->hasAttribute(Attribute::PLACEHOLDER)) {
                continue;
            }
            $placeholder = $childNode;
            while ($placeholder !== null) {
                if (!$placeholder instanceof Element) {
                    $placeholder = NodeWalker::nextNode($placeholder);
                    continue;
                }
                if ($placeholder->tagName === Extension::IMG || $placeholder->tagName === Tag::IMG) {
                    // Found valid candidate for placeholder image.
                    break;
                }
                if (Amp::isTemplate($placeholder)) {
                    // Ignore images inside templates.
                    $placeholder = NodeWalker::skipNodeAndChildren($placeholder);
                } else {
                    $placeholder = NodeWalker::nextNode($placeholder);
                }
            }
            if (!$placeholder instanceof Element) {
                break;
            }
            $src = $placeholder->getAttribute(Attribute::SRC);
            if (!(new Url($src))->isValidNonDataUrl()) {
                break;
            }
            return new HeroImage($src, $element->getAttribute(Attribute::MEDIA), $placeholder->getAttribute(Attribute::SRCSET), $placeholder);
        }
        return null;
    }
    /**
     * Remove the lazy loading from the hero image.
     *
     * @param HeroImage $heroImage Hero image to remove the lazy loading for.
     */
    private function removeLazyLoading(HeroImage $heroImage)
    {
        $img = $heroImage->getAmpImg();
        if ($img && $img->getAttribute(Attribute::LOADING) === 'lazy' && !$img->hasAttribute(Attribute::DATA_AMP_STORY_PLAYER_POSTER_IMG)) {
            $img->removeAttribute(Attribute::LOADING);
        }
    }
    /**
     * Generate the preload link for a given hero image.
     *
     * @param HeroImage       $heroImage Hero image to generate the preload link for.
     * @param Document        $document  Document to generate the preload link in.
     * @param ErrorCollection $errors    Collection of errors that are collected during transformation.
     */
    private function generatePreload(HeroImage $heroImage, Document $document, ErrorCollection $errors)
    {
        if (empty($heroImage->getMedia())) {
            // We can only safely preload a hero image if there's a media attribute
            // as we can't detect whether it's hidden on certain viewport sizes otherwise.
            return;
        }
        if ($heroImage->getSrcset() && !$this->supportsSrcset()) {
            $errors->add(Error\CannotPreloadImage::fromImageWithSrcsetAttribute($heroImage->getAmpImg()));
            return;
        }
        if ($this->hasExistingImagePreload($document, $heroImage->getSrc())) {
            return;
        }
        if ($this->preloadReferenceNode === null) {
            $this->preloadReferenceNode = $document->viewport;
        }
        $preload = $document->createElement(Tag::LINK);
        $preload->setAttribute(Attribute::REL, Attribute::REL_PRELOAD);
        $preload->setAttribute(Attribute::HREF, $heroImage->getSrc());
        $preload->setAttribute(Attribute::AS_, RequestDestination::IMAGE);
        $preload->appendChild($document->createAttribute(Attribute::DATA_HERO));
        if ($heroImage->getSrcset()) {
            $preload->setAttribute(Attribute::IMAGESRCSET, $heroImage->getSrcset());
            $img = $heroImage->getAmpImg();
            if ($img && $img->hasAttribute(Attribute::SIZES)) {
                $preload->setAttribute(Attribute::IMAGESIZES, $img->getAttribute(Attribute::SIZES));
            }
        }
        $preload->setAttribute(Attribute::MEDIA, $heroImage->getMedia());
        if ($this->preloadReferenceNode) {
            $this->preloadReferenceNode->parentNode->insertBefore($preload, $this->preloadReferenceNode->nextSibling);
        } else {
            $document->head->appendChild($preload);
        }
        $this->preloadReferenceNode = $preload;
    }
    /**
     * Generate the SSR image element for the hero image.
     *
     * @param HeroImage $heroImage Hero image to generate the SSR image element for.
     * @param Document  $document  Document in which to generate the SSR image element in.
     */
    private function generateImg(HeroImage $heroImage, Document $document)
    {
        $element = $heroImage->getAmpImg();
        if (!$element || $element->tagName !== Extension::IMG) {
            return;
        }
        $imgElement = $document->createElement(Tag::IMG);
        $imgElement->setAttribute(Attribute::CLASS_, self::SSR_IMAGE_CLASS);
        $imgElement->setAttribute(Attribute::DECODING, 'async');
        // If the image was detected as hero image candidate (and thus lacks an explicit data-hero), mark it as a hero
        // and add loading=lazy to guard against making the page performance even worse by eagerly loading an image
        // outside the viewport. But if there is a noscript > img then preserve its original loading attribute.
        $noscript_img = $document->xpath->query(self::NOSCRIPT_IMG_XPATH_QUERY, $element)->item(0);
        if ($noscript_img instanceof Element) {
            // Preserve the original loading attribute from the noscript fallback img.
            if ($noscript_img->hasAttribute(Attribute::LOADING)) {
                $imgElement->setAttribute(Attribute::LOADING, $noscript_img->getAttribute(Attribute::LOADING));
            }
            // Preserve the original fetchpriority attribute from the noscript fallback img.
            if ($noscript_img->hasAttribute(Attribute::FETCHPRIORITY)) {
                $imgElement->setAttribute(Attribute::FETCHPRIORITY, $noscript_img->getAttribute(Attribute::FETCHPRIORITY));
            }
            // Remove any noscript>img when an amp-img is pre-rendered.
            $noscript_img->parentNode->parentNode->removeChild($noscript_img->parentNode);
        } elseif (!$this->isMarkedAsHeroImage($element)) {
            $imgElement->setAttribute(Attribute::LOADING, 'lazy');
        }
        if (!$element->hasAttribute(Attribute::DATA_HERO)) {
            $element->appendChild($document->createAttribute(Attribute::DATA_HERO));
        }
        foreach (self::ATTRIBUTES_TO_COPY as $attribute) {
            if ($element->hasAttribute($attribute)) {
                $imgElement->setAttribute($attribute, $element->getAttribute($attribute));
            }
        }
        foreach (self::ATTRIBUTES_TO_INLINE as $attribute) {
            if ($element->hasAttribute($attribute)) {
                $value = $element->getAttribute($attribute);
                $style = empty($value) ? '' : "{$attribute}:{$element->getAttribute($attribute)}";
                $imgElement->addInlineStyle($style);
            }
        }
        $element->appendChild($document->createAttribute(Attribute::I_AMPHTML_SSR));
        $element->appendChild($imgElement);
    }
    /**
     * Check whether an existing preload link exists for a given src.
     *
     * @param Document $document Document in which to check for an existing preload.
     * @param string   $src      Preload URL to look for.
     * @return bool Whether an existing preload already exists.
     */
    private function hasExistingImagePreload(Document $document, $src)
    {
        foreach ($document->head->childNodes as $node) {
            if (!$node instanceof Element) {
                continue;
            }
            if ($node->getAttribute(Attribute::REL) !== Attribute::REL_PRELOAD) {
                continue;
            }
            if ($node->getAttribute(Attribute::AS_) !== RequestDestination::IMAGE) {
                continue;
            }
            if ($node->getAttribute(Attribute::HREF) === $src) {
                return \true;
            }
        }
        return \false;
    }
    /**
     * Check whether a given element is an AMP embed.
     *
     * @param Element $element Element to check.
     * @return bool Whether the given element is an AMP embed.
     */
    private function isAmpEmbed(Element $element)
    {
        return \array_key_exists($element->tagName, self::AMP_EMBEDS);
    }
    /**
     * Get the URL of the CSS background-image property.
     *
     * This falls back to the data-amp-original-style attribute if the inline
     * style was already extracted by the CSS tree-shaking.
     *
     * @param Element $element Element to detect for.
     * @return string URL of the background image, or an empty string if not found.
     */
    private function getCssBackgroundImageUrl(Element $element)
    {
        $matches = [];
        if (\preg_match(self::CSS_BACKGROUND_IMAGE_URL_REGEX_PATTERN, $element->getAttribute(Attribute::STYLE), $matches)) {
            return \trim($matches['url'], '\'" ');
        }
        if (!empty($this->inlineStyleBackupAttribute) && \preg_match(self::CSS_BACKGROUND_IMAGE_URL_REGEX_PATTERN, $element->getAttribute($this->inlineStyleBackupAttribute), $matches)) {
            return \trim($matches['url'], '\'" ');
        }
        return '';
    }
    /**
     * Whether srcset preloading is supported.
     *
     * @return bool
     */
    private function supportsSrcset()
    {
        return $this->configuration->get(OptimizeHeroImagesConfiguration::PRELOAD_SRCSET);
    }
    /**
     * Check if an element or its ancestors is marked as a hero image.
     *
     * @param Element $element Element to check.
     * @return bool Whether the element or one of its ancestors is marked as a hero image.
     */
    private function isMarkedAsHeroImage(Element $element)
    {
        while ($element) {
            if (!$element instanceof Element) {
                $element = $element->parentNode;
                continue;
            }
            if ($element->hasAttribute(Attribute::DATA_HERO)) {
                return \true;
            }
            if ($element->tagName === Tag::BODY || $element->tagName === Tag::HTML) {
                return \false;
            }
            $element = $element->parentNode;
        }
        return \false;
    }
}
