<?php

namespace Google\Web_Stories_Dependencies;

/**
 * Class AMP_Image_Dimension_Extractor
 *
 * @package AMP
 */
/**
 * Class with static methods to extract image dimensions.
 *
 * @internal
 */
class AMP_Image_Dimension_Extractor
{
    const STATUS_FAILED_LAST_ATTEMPT = 'failed';
    const STATUS_IMAGE_EXTRACTION_FAILED = 'failed';
    /**
     * Internal flag whether callbacks have been registered.
     *
     * @var bool
     */
    private static $callbacks_registered = \false;
    /**
     * Extracts dimensions from image URLs.
     *
     * @since 0.2
     *
     * @param array|string $urls Array of URLs to extract dimensions from, or a single URL string.
     * @return array|string Extracted dimensions keyed by original URL, or else the single set of dimensions if one URL string is passed.
     */
    public static function extract($urls)
    {
        if (!self::$callbacks_registered) {
            self::register_callbacks();
        }
        $return_dimensions = [];
        // Back-compat for users calling this method directly.
        $is_single = \is_string($urls);
        if ($is_single) {
            $urls = [$urls];
        }
        // Normalize URLs and also track a map of normalized-to-original as we'll need it to reformat things when returning the data.
        $url_map = [];
        $normalized_urls = [];
        foreach ($urls as $original_url) {
            $normalized_url = self::normalize_url($original_url);
            if (\false !== $normalized_url) {
                $url_map[$original_url] = $normalized_url;
                $normalized_urls[] = $normalized_url;
            } else {
                // This is not a URL we can extract dimensions from, so default to false.
                $return_dimensions[$original_url] = \false;
            }
        }
        $extracted_dimensions = \array_fill_keys($normalized_urls, \false);
        /**
         * Filters the dimensions extracted from image URLs.
         *
         * @since 0.5.1
         *
         * @param array $extracted_dimensions Extracted dimensions, initially mapping images URLs to false.
         */
        $extracted_dimensions = \apply_filters('amp_extract_image_dimensions_batch', $extracted_dimensions);
        // We need to return a map with the original (un-normalized URL) as we that to match nodes that need dimensions.
        foreach ($url_map as $original_url => $normalized_url) {
            $return_dimensions[$original_url] = $extracted_dimensions[$normalized_url];
        }
        // Back-compat: just return the dimensions, not the full mapped array.
        if ($is_single) {
            return \current($return_dimensions);
        }
        return $return_dimensions;
    }
    /**
     * Normalizes the given URL.
     *
     * This method ensures the URL has a scheme and, if relative, is prepended the WordPress site URL.
     *
     * @param string $url URL to normalize.
     * @return string|false Normalized URL, or false if normalization failed.
     */
    public static function normalize_url($url)
    {
        if (empty($url)) {
            return \false;
        }
        if (0 === \strpos($url, 'data:')) {
            return \false;
        }
        $normalized_url = $url;
        if (0 === \strpos($url, '//')) {
            $normalized_url = \set_url_scheme($url, 'http');
        } else {
            $parsed = \wp_parse_url($url);
            if (!isset($parsed['host'])) {
                $path = '';
                if (isset($parsed['path'])) {
                    $path .= $parsed['path'];
                }
                if (isset($parsed['query'])) {
                    $path .= '?' . $parsed['query'];
                }
                $home = \home_url();
                $home_path = \wp_parse_url($home, \PHP_URL_PATH);
                if (!empty($home_path)) {
                    $home = \substr($home, 0, -\strlen($home_path));
                }
                $normalized_url = $home . $path;
            }
        }
        /**
         * Apply filters on the normalized image URL for dimension extraction.
         *
         * @since 1.1
         *
         * @param string $normalized_url Normalized image URL.
         * @param string $url            Original image URL.
         */
        $normalized_url = \apply_filters('amp_normalized_dimension_extractor_image_url', $normalized_url, $url);
        return $normalized_url;
    }
    /**
     * Registers the necessary callbacks.
     */
    private static function register_callbacks()
    {
        self::$callbacks_registered = \true;
        \add_filter('amp_extract_image_dimensions_batch', [__CLASS__, 'extract_by_filename_or_filesystem'], 100);
        \add_filter('amp_extract_image_dimensions_batch', [__CLASS__, 'extract_by_downloading_images'], 999, 1);
        /**
         * Fires after the amp_extract_image_dimensions_batch filter has been added to extract by downloading images.
         *
         * @since 0.5.1
         */
        \do_action('amp_extract_image_dimensions_batch_callbacks_registered');
    }
    /**
     * Extract dimensions from filename if dimension exists or from file system.
     *
     * @param array $dimensions Image urls mapped to dimensions.
     * @return array Dimensions mapped to image urls, or false if they could not be retrieved
     */
    public static function extract_by_filename_or_filesystem($dimensions)
    {
        if (empty($dimensions) || !\is_array($dimensions)) {
            return [];
        }
        $using_ext_object_cache = \wp_using_ext_object_cache();
        $ext_types = \wp_get_ext_types();
        if (empty($ext_types['image'])) {
            return $dimensions;
        }
        $image_ext_types = $ext_types['image'];
        unset($ext_types);
        $upload_dir = \wp_get_upload_dir();
        $base_upload_uri = \trailingslashit($upload_dir['baseurl']);
        $base_upload_dir = \trailingslashit($upload_dir['basedir']);
        foreach ($dimensions as $url => $value) {
            // Check whether some other callback attached to the filter already provided dimensions for this image.
            if (!empty($value) && \is_array($value)) {
                continue;
            }
            $url_without_query_fragment = \strtok($url, '?#');
            // Parse info out of the URL, including the file extension and (optionally) the dimensions.
            if (!\preg_match('/(?:-(?<width>[1-9][0-9]*)x(?<height>[1-9][0-9]*))?\\.(?<ext>\\w+)$/', $url_without_query_fragment, $matches)) {
                continue;
            }
            // Skip images don't have recognized extensions.
            if (!\in_array(\strtolower($matches['ext']), $image_ext_types, \true)) {
                continue;
            }
            // Use image dimension from the file name.
            if (!empty($matches['width']) && !empty($matches['height'])) {
                $dimensions[$url] = ['width' => (int) $matches['width'], 'height' => (int) $matches['height']];
                continue;
            }
            // Verify that the URL is for an uploaded file.
            if (0 !== \strpos($url_without_query_fragment, $base_upload_uri)) {
                continue;
            }
            $upload_relative_path = \substr($url_without_query_fragment, \strlen($base_upload_uri));
            // Bail if the URL contains relative paths.
            if (0 !== \validate_file($upload_relative_path)) {
                continue;
            }
            // Get image dimension from file system.
            $image_file = $base_upload_dir . $upload_relative_path;
            $image_size = [];
            list($transient_name) = self::get_transient_names($url);
            // When using an external object cache, try to first see if dimensions have already been obtained. This is
            // not done for a non-external object cache (i.e. when wp_options is used for transients) because then
            // we are not storing the dimensions in a transient, because it is more performant to read the dimensions
            // from the filesystem--both in terms of time and storage--than to store dimensions in wp_options.
            if ($using_ext_object_cache) {
                $image_size = \get_transient($transient_name);
                $image_size = !empty($image_size) && \is_array($image_size) ? $image_size : [];
            }
            if (empty($image_size) && \file_exists($image_file)) {
                if (\function_exists('wp_getimagesize')) {
                    $image_size = \wp_getimagesize($image_file);
                } elseif (\function_exists('getimagesize')) {
                    $image_size = @\getimagesize($image_file);
                    // phpcs:ignore WordPress.PHP.NoSilencedErrors
                }
                if ($using_ext_object_cache && !empty($image_size) && \is_array($image_size)) {
                    \set_transient($transient_name, $image_size);
                }
            }
            if (!empty($image_size) && \is_array($image_size)) {
                $dimensions[$url] = ['width' => (int) $image_size[0], 'height' => (int) $image_size[1]];
            }
        }
        return $dimensions;
    }
    /**
     * Get transient names.
     *
     * @param string $url Image URL.
     * @return array {
     *     @type string $0 Transient name for storing dimensions.
     *     @type string $1 Transient name for image fetching lock.
     * }
     */
    private static function get_transient_names($url)
    {
        $url_hash = \md5($url);
        return [\sprintf('amp_img_%s', $url_hash), \sprintf('amp_lock_%s', $url_hash)];
    }
    /**
     * Extract dimensions from downloaded images (or transient/cached dimensions from downloaded images)
     *
     * @param array $dimensions Image urls mapped to dimensions.
     * @param bool  $mode       Deprecated.
     * @return array Dimensions mapped to image urls, or false if they could not be retrieved
     */
    public static function extract_by_downloading_images($dimensions, $mode = \false)
    {
        if ($mode) {
            \_deprecated_argument(__METHOD__, 'AMP 1.1');
        }
        $transient_expiration = 30 * \DAY_IN_SECONDS;
        $urls_to_fetch = [];
        $images = [];
        self::determine_which_images_to_fetch($dimensions, $urls_to_fetch);
        try {
            self::fetch_images($urls_to_fetch, $images);
            self::process_fetched_images($urls_to_fetch, $images, $dimensions, $transient_expiration);
        } catch (\Exception $exception) {
            \trigger_error(\esc_html($exception->getMessage()), \E_USER_WARNING);
            // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error
        }
        return $dimensions;
    }
    /**
     * Determine which images to fetch by checking for dimensions in transient/cache.
     * Creates a short lived transient that acts as a semaphore so that another visitor
     * doesn't trigger a remote fetch for the same image at the same time.
     *
     * @param array $dimensions Image urls mapped to dimensions.
     * @param array $urls_to_fetch Urls of images to fetch because dimensions are not in transient/cache.
     */
    private static function determine_which_images_to_fetch(&$dimensions, &$urls_to_fetch)
    {
        foreach ($dimensions as $url => $value) {
            // Check whether some other callback attached to the filter already provided dimensions for this image.
            if (\is_array($value) || empty($url)) {
                continue;
            }
            list($transient_name, $transient_lock_name) = self::get_transient_names($url);
            $cached_dimensions = \get_transient($transient_name);
            // If we're able to retrieve the dimensions from a transient, set them and move on.
            if (\is_array($cached_dimensions)) {
                $dimensions[$url] = ['width' => $cached_dimensions[0], 'height' => $cached_dimensions[1]];
                continue;
            }
            // If the value in the transient reflects we couldn't get dimensions for this image the last time we tried, move on.
            if (self::STATUS_FAILED_LAST_ATTEMPT === $cached_dimensions) {
                $dimensions[$url] = \false;
                continue;
            }
            // If somebody is already trying to extract dimensions for this transient right now, move on.
            if (\false !== \get_transient($transient_lock_name)) {
                $dimensions[$url] = \false;
                continue;
            }
            // Include the image as a url to fetch.
            $urls_to_fetch[$url] = [];
            $urls_to_fetch[$url]['url'] = $url;
            $urls_to_fetch[$url]['transient_name'] = $transient_name;
            $urls_to_fetch[$url]['transient_lock_name'] = $transient_lock_name;
            \set_transient($transient_lock_name, 1, \MINUTE_IN_SECONDS);
        }
    }
    /**
     * Fetch dimensions of remote images
     *
     * @throws Exception When cURL handle cannot be added.
     *
     * @param array $urls_to_fetch Image src urls to fetch.
     * @param array $images Array to populate with results of image/dimension inspection.
     */
    private static function fetch_images($urls_to_fetch, &$images)
    {
        $urls = \array_keys($urls_to_fetch);
        $client = new \Google\Web_Stories_Dependencies\FasterImage\FasterImage();
        /**
         * Filters the user agent for obtaining the image dimensions.
         *
         * @param string $user_agent User agent.
         */
        $client->setUserAgent(\apply_filters('amp_extract_image_dimensions_get_user_agent', self::get_default_user_agent()));
        $client->setBufferSize(1024);
        $client->setSslVerifyHost(\true);
        $client->setSslVerifyPeer(\true);
        $images = $client->batch($urls);
    }
    /**
     * Determine success or failure of remote fetch, integrate fetched dimensions into url to dimension mapping,
     * cache fetched dimensions via transient and release/delete semaphore transient
     *
     * @param array $urls_to_fetch List of image urls that were fetched and transient names corresponding to each (for unlocking semaphore, setting "real" transient).
     * @param array $images Results of remote fetch mapping fetched image url to dimensions.
     * @param array $dimensions Map of image url to dimensions to be updated with results of remote fetch.
     * @param int   $transient_expiration Duration image dimensions should exist in transient/cache.
     */
    private static function process_fetched_images($urls_to_fetch, $images, &$dimensions, $transient_expiration)
    {
        foreach ($urls_to_fetch as $url_data) {
            $image_data = $images[$url_data['url']];
            if (self::STATUS_IMAGE_EXTRACTION_FAILED === $image_data['size']) {
                $dimensions[$url_data['url']] = \false;
                \set_transient($url_data['transient_name'], self::STATUS_FAILED_LAST_ATTEMPT, $transient_expiration);
            } else {
                $dimensions[$url_data['url']] = ['width' => $image_data['size'][0], 'height' => $image_data['size'][1]];
                \set_transient($url_data['transient_name'], [$image_data['size'][0], $image_data['size'][1]], $transient_expiration);
            }
            \delete_transient($url_data['transient_lock_name']);
        }
    }
    /**
     * Get default user agent
     *
     * @return string
     */
    public static function get_default_user_agent()
    {
        return 'amp-wp, v' . \AMP__VERSION . ', ' . \home_url();
    }
}
