<?php
namespace WP_Rocket\Engine\Media\Lazyload\CSS;

use WP_Filesystem_Direct;
use WP_Post;
use WP_Rocket\Admin\Options_Data;
use WP_Rocket\Engine\Common\Context\ContextInterface;
use WP_Rocket\Engine\Media\Lazyload\CSS\Data\LazyloadedContent;
use WP_Rocket\Engine\Media\Lazyload\CSS\Data\LazyloadCSSContentFactory;
use WP_Rocket\Engine\Media\Lazyload\CSS\Data\ProtectedContent;
use WP_Rocket\Engine\Media\Lazyload\CSS\Front\{ContentFetcher,
	Extractor,
	FileResolver,
	MappingFormatter,
	RuleFormatter,
	TagGenerator};
use WP_Rocket\Engine\Common\Cache\CacheInterface;
use WP_Rocket\Engine\Optimization\RegexTrait;
use WP_Rocket\Engine\Support\CommentTrait;
use WP_Rocket\Event_Management\Subscriber_Interface;
use WP_Rocket\Logger\LoggerAware;
use WP_Rocket\Logger\LoggerAwareInterface;

class Subscriber implements Subscriber_Interface, LoggerAwareInterface {
	use LoggerAware;
	use RegexTrait;
	use CommentTrait;

	/**
	 * Extract background images from CSS.
	 *
	 * @var Extractor
	 */
	protected $extractor;

	/**
	 * Cache instance.
	 *
	 * @var CacheInterface
	 */
	protected $cache;

	/**
	 * Format the CSS rule inside the CSS content.
	 *
	 * @var RuleFormatter
	 */
	protected $rule_formatter;

	/**
	 * Resolves the name from the file from its URL.
	 *
	 * @var FileResolver
	 */
	protected $file_resolver;

	/**
	 * Format data for the Mapping file.
	 *
	 * @var MappingFormatter
	 */
	protected $mapping_formatter;

	/**
	 * Generate tags from the mapping of lazyloaded images.
	 *
	 * @var TagGenerator
	 */
	protected $tag_generator;

	/**
	 * Fetch content.
	 *
	 * @var ContentFetcher
	 */
	protected $fetcher;

	/**
	 * Context.
	 *
	 * @var ContextInterface
	 */
	protected $context;

	/**
	 * WPR Options.
	 *
	 * @var Options_Data
	 */
	protected $options;

	/**
	 * Make LazyloadedContent instance.
	 *
	 * @var LazyloadCSSContentFactory
	 */
	protected $lazyloaded_content_factory;

	/**
	 * WordPress filesystem.
	 *
	 * @var WP_Filesystem_Direct
	 */
	protected $filesystem;

	/**
	 * Instantiate class.
	 *
	 * @param Extractor                 $extractor Extract background images from CSS.
	 * @param RuleFormatter             $rule_formatter Format the CSS rule inside the CSS content.
	 * @param FileResolver              $file_resolver Resolves the name from the file from its URL.
	 * @param CacheInterface            $cache Cache instance.
	 * @param MappingFormatter          $mapping_formatter Format data for the Mapping file.
	 * @param TagGenerator              $tag_generator Generate tags from the mapping of lazy loaded images.
	 * @param ContentFetcher            $fetcher Fetch content.
	 * @param ContextInterface          $context Context.
	 * @param Options_Data              $options WPR Options.
	 * @param LazyloadCSSContentFactory $lazyloaded_content_factory Make LazyloadedContent instance.
	 * @param WP_Filesystem_Direct|null $filesystem WordPress filesystem.
	 */
	public function __construct( Extractor $extractor, RuleFormatter $rule_formatter, FileResolver $file_resolver, CacheInterface $cache, MappingFormatter $mapping_formatter, TagGenerator $tag_generator, ContentFetcher $fetcher, ContextInterface $context, Options_Data $options, LazyloadCSSContentFactory $lazyloaded_content_factory, ?WP_Filesystem_Direct $filesystem = null ) {
		$this->extractor                  = $extractor;
		$this->cache                      = $cache;
		$this->rule_formatter             = $rule_formatter;
		$this->file_resolver              = $file_resolver;
		$this->mapping_formatter          = $mapping_formatter;
		$this->tag_generator              = $tag_generator;
		$this->context                    = $context;
		$this->options                    = $options;
		$this->fetcher                    = $fetcher;
		$this->lazyloaded_content_factory = $lazyloaded_content_factory;
		$this->filesystem                 = $filesystem ?: rocket_direct_filesystem();
	}

	/**
	 * Returns an array of events that this subscriber wants to listen to.
	 *
	 * @return array
	 */
	public static function get_subscribed_events() {
		return [
			'rocket_generate_lazyloaded_css'        => [
				[ 'create_lazy_css_files', 18 ],
				[ 'create_lazy_inline_css', 21 ],
				[ 'add_lazy_tag', 24 ],
			],
			'rocket_buffer'                         => [ 'maybe_replace_css_images', 110000 ],
			'rocket_after_clean_domain'             => 'clear_generated_css',
			'wp_enqueue_scripts'                    => 'insert_lazyload_script',
			'rocket_css_image_lazyload_images_load' => [ 'exclude_rocket_lazyload_excluded_src', 10, 2 ],
			'rocket_lazyload_css_ignored_urls'      => 'remove_svg_from_lazyload_css',
		];
	}

	/**
	 * Replace CSS images by the lazyloaded version.
	 *
	 * @param string $html page HTML.
	 * @return string
	 */
	public function maybe_replace_css_images( string $html ): string {

		if ( ! $this->context->is_allowed() ) {
			return $html;
		}

		$this->logger::debug(
			'Starting lazyload',
			$this->generate_log_context(
				[
					'data' => $html,
				]
				)
			);

		/**
		 * Generate lazyload CSS for the page.
		 *
		 * @param array $data Data passed to generate the lazyload CSS.
		 */
		$output = wpm_apply_filters_typed(
			'array',
			'rocket_generate_lazyloaded_css',
			[
				'html' => $html,
			]
		);

		if ( ! key_exists( 'html', $output ) ) {
			$this->logger::debug(
				'Lazyload bailed out',
				$this->generate_log_context(
					[
						'data' => $html,
					]
					)
				);
			return $html;
		}

		$this->logger::debug(
			'Ending lazyload',
			$this->generate_log_context(
				[
					'data' => $html,
				]
				)
			);

		$html = $this->add_meta_comment( 'lazyload_css_bg_img', $output['html'] );

		return $html;
	}

	/**
	 * Clear the lazyload CSS files.
	 *
	 * @return void
	 */
	public function clear_generated_css() {
		$this->logger::debug(
			'Clear lazy CSS',
			$this->generate_log_context()
		);
		$this->cache->clear();
	}

	/**
	 * Insert the lazyload script.
	 *
	 * @return void
	 */
	public function insert_lazyload_script() {
		if ( ! $this->context->is_allowed() ) {
			return;
		}

		/**
		 * Filters the threshold at which lazyload is triggered
		 *
		 * @since 1.2
		 *
		 * @param int $threshold Threshold value.
		 */
		$threshold = (int) apply_filters( 'rocket_lazyload_threshold', 300 );

		$script_path = rocket_get_constant( 'WP_ROCKET_ASSETS_JS_PATH' ) . 'lazyload-css.min.js';

		if ( ! $this->filesystem->exists( $script_path ) ) {
			return;
		}

		$content = $this->filesystem->get_contents( $script_path );

		if ( ! $content ) {
			return;
		}

		wp_register_script( 'rocket_lazyload_css', '', [], false, true ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.NoExplicitVersion
		wp_enqueue_script( 'rocket_lazyload_css' );
		wp_add_inline_script( 'rocket_lazyload_css', $content, 'after' );
		wp_localize_script(
			'rocket_lazyload_css',
			'rocket_lazyload_css_data',
			[
				'threshold' => $threshold,
			]
			);
	}

	/**
	 * Create the lazyload file for CSS files.
	 *
	 * @param array $data Data sent.
	 * @return array
	 */
	public function create_lazy_css_files( array $data ): array {
		if ( ! key_exists( 'html', $data ) || ! key_exists( 'css_files', $data ) ) {
			$this->logger::debug(
				'Create lazy css files bailed out',
				$this->generate_log_context(
					[
						'data' => $data,
					]
					)
			);
			return $data;
		}

		$html    = $data['html'];
		$html    = $this->replace_html_comments( $html );
		$mapping = [];

		$css_files = array_unique( $data['css_files'] );

		$protected_content = $this->protect_css_urls( $html, $css_files );

		$html              = $protected_content->get_content();
		$css_files_mapping = $protected_content->get_protected_files_mapping();

		foreach ( $css_files as $url ) {

			if ( $this->is_excluded( $url ) ) {
				$this->logger::debug(
					"Excluded lazy css files $url",
					$this->generate_log_context()
				);
				continue;
			}

			$url_key = $this->format_url( $url );
			if ( ! $this->cache->has( $url_key ) ) {
				$this->logger::debug(
					"Generate lazy css files $url",
					$this->generate_log_context()
					);

				$file_mapping = $this->generate_css_file( $url );
				if ( empty( $file_mapping ) ) {
					$this->logger::debug(
						"Create lazy css files $url bailed out",
						$this->generate_log_context()
						);
					continue;
				}

				$mapping = array_merge( $mapping, $file_mapping );

			} else {
				$this->logger::debug(
					"Load lazy css files $url",
					$this->generate_log_context()
					);
				$mapping = array_merge( $mapping, $this->load_existing_mapping( $url ) );
			}

			$cached_url = $this->generate_asset_url( $url );

			$html = str_replace( $css_files_mapping[ $url ], $cached_url, $html );
		}

		foreach ( $css_files_mapping as $url => $placeholder ) {
			$html = str_replace( $placeholder, $url, $html );
		}

		$html = $this->restore_html_comments( $html );

		$data['html'] = $html;

		if ( ! key_exists( 'lazyloaded_images', $data ) ) {
			$data['lazyloaded_images'] = [];
		}

		$data['lazyloaded_images'] = array_merge( $data['lazyloaded_images'], $mapping );
		$data['lazyloaded_images'] = array_unique( $data['lazyloaded_images'], SORT_REGULAR );

		return $data;
	}

	/**
	 * Add the lazy tag to the current HTML.
	 *
	 * @param array $data Data sent.
	 * @return array
	 */
	public function add_lazy_tag( array $data ): array {

		if ( ! key_exists( 'html', $data ) || ! key_exists( 'lazyloaded_images', $data ) ) {
			$this->logger::debug(
				'Add lazy tag bailed out',
				$this->generate_log_context(
					[
						'data' => $data,
					]
					)
				);
			return $data;
		}

		$lazyload_images = $data['lazyloaded_images'];

		/**
		 * Lazyload background CSS excluded urls.
		 *
		 * @param array $excluded Excluded URLs.
		 * @param array $urls List of Urls processed.
		 */
		$loaded = apply_filters( 'rocket_css_image_lazyload_images_load', [], $lazyload_images );

		$tags = $this->tag_generator->generate( $lazyload_images, $loaded );
		$this->logger::debug(
			'Add lazy tag generated',
			$this->generate_log_context(
				[
					'data' => $tags,
				]
				)
			);
		$data['html'] = str_replace( '</head>', "$tags</head>", $data['html'] );

		return $data;
	}

	/**
	 * Generate lazy CSS for a file.
	 *
	 * @param string $url Url from the CSS.
	 * @return array
	 */
	protected function generate_css_file( string $url ) {
		$path = $this->file_resolver->resolve( $url );

		if ( ! $path ) {
			$path = $url;
		}

		$content = $this->fetcher->fetch( $path, $this->cache->generate_path( $url ) );

		if ( ! $content ) {
			return [];
		}

		$output = $this->generate_content( $content, $this->cache->generate_url( $url ) );

		if ( count( $output->get_urls() ) === 0 ) {
			return [];
		}

		if ( ! $this->cache->set( $this->format_url( $url ), $output->get_content() ) ) {
			return [];
		}

		$this->cache->set( $this->get_mapping_file_url( $url ), json_encode( $output->get_urls() ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode

		return $output->get_urls();
	}

	/**
	 * Generate lazy content for a certain content.
	 *
	 * @param string $content Content to generate lazy for.
	 * @param string $url URL of the file we are extracting content from.
	 * @return LazyloadedContent
	 */
	protected function generate_content( string $content, string $url = '' ): LazyloadedContent {
		$urls           = $this->extractor->extract( $content, $url );
		$formatted_urls = [];
		foreach ( $urls as $url_tags ) {
			$url_tags       = $this->add_hashes( $url_tags );
			$content        = $this->rule_formatter->format( $content, $url_tags );
			$formatted_urls = array_merge( $formatted_urls, $this->mapping_formatter->format( $url_tags ) );
		}

		return $this->lazyloaded_content_factory->make_lazyloaded_content( $formatted_urls, $content );
	}

	/**
	 * Load existing mapping for a URL.
	 *
	 * @param string $url Url we load mapping for.
	 * @return array
	 */
	protected function load_existing_mapping( string $url ) {
		$content = $this->cache->get( $this->get_mapping_file_url( $url ) );
		$urls    = json_decode( $content, true );
		if ( ! $urls ) {
			return [];
		}
		return $urls;
	}

	/**
	 * Create the lazyload file for inline CSS.
	 *
	 * @param array $data Data sent.
	 * @return array
	 */
	public function create_lazy_inline_css( array $data ): array {

		if ( ! key_exists( 'html', $data ) || ! key_exists( 'css_inline', $data ) ) {
			$this->logger::debug(
				'Create lazy css inline bailed out',
				$this->generate_log_context(
					[
						'data' => $data,
					]
					)
				);
			return $data;
		}

		$html = $data['html'];

		if ( ! key_exists( 'lazyloaded_images', $data ) ) {
			$data['lazyloaded_images'] = [];
		}

		foreach ( $data['css_inline'] as $content ) {

			$output = $this->generate_content( $content );

			$html = str_replace( $content, $output->get_content(), $html );

			$data['lazyloaded_images'] = array_merge( $data['lazyloaded_images'], $output->get_urls() );
		}

		$data['html'] = $html;

		return $data;
	}

	/**
	 * Format a URL.
	 *
	 * @param string $url URL to format.
	 * @return string
	 */
	protected function format_url( string $url ): string {
		return strtok( $url, '?' );
	}

	/**
	 * Check of the string is excluded.
	 *
	 * @param string $string String to check.
	 * @return bool
	 */
	protected function is_excluded( string $string ) { // phpcs:ignore Universal.NamingConventions.NoReservedKeywordParameterNames.stringFound

		$values = [
			$string,
		];

		$parsed_url_host = wp_parse_url( $string, PHP_URL_HOST );

		if ( ! $parsed_url_host ) {
			$values [] = rocket_get_home_url() . $string;
		}

		/**
		 * Filters the src used to prevent lazy load from being applied.
		 *
		 * @param array $excluded_src An array of excluded src.
		 */
		$excluded_values = wpm_apply_filters_typed( 'array', 'rocket_lazyload_excluded_src', [] );

		$excluded_values = array_filter( $excluded_values );

		if ( empty( $excluded_values ) ) {
			return false;
		}

		foreach ( $values as $value ) {
			foreach ( $excluded_values as $excluded_value ) {
				if ( strpos( $value, $excluded_value ) !== false ) {
					return true;
				}
			}
		}

		return false;
	}

	/**
	 * Is the feature activated.
	 *
	 * @return bool
	 */
	protected function is_activated(): bool {
		return (bool) $this->options->get( 'lazyload_css_bg_img', false );
	}

	/**
	 * Add lazyload_excluded_src to excluded filters.
	 *
	 * @param array $excluded Excluded URLs.
	 * @param array $urls List of Urls processed.
	 * @return mixed
	 */
	public function exclude_rocket_lazyload_excluded_src( $excluded, $urls ) {

		/**
		 * Filters the src used to prevent lazy load from being applied.
		 *
		 * @param array $excluded_src An array of excluded src.
		 */
		$excluded_values = wpm_apply_filters_typed( 'array', 'rocket_lazyload_excluded_src', [] );

		$excluded_values = array_filter( $excluded_values );

		if ( empty( $excluded_values ) ) {
			return $excluded;
		}

		foreach ( $urls as $url ) {
			foreach ( $excluded_values as $excluded_value ) {
				if ( strpos( $url['selector'], $excluded_value ) !== false || strpos( $url['style'], $excluded_value ) !== false ) {
					$excluded[] = $url;
					break;
				}
			}
		}

		return $excluded;
	}

	/**
	 * Add hashes.
	 *
	 * @param array $tags Tags.
	 * @return array
	 */
	protected function add_hashes( array $tags ): array {
		return array_map(
			function ( $url_tag ) {
				/**
				 * Lazyload CSS hash.
				 *
				 * @param string $hash Lazyload CSS hash.
				 * @param mixed  $url_tag URL tag.
				 */
				$url_tag['hash'] = apply_filters( 'rocket_lazyload_css_hash',  wp_generate_uuid4(), $url_tag );
				return $url_tag;
			},
			$tags
		);
	}

	/**
	 * Return mapping file URL.
	 *
	 * @param string $url Resource URL.
	 * @return string
	 */
	protected function get_mapping_file_url( string $url ): string {
		return $this->format_url( $url ) . '.json';
	}

	/**
	 * Add timestamp to URL.
	 *
	 * @param string $url Asset Url.
	 *
	 * @return string
	 */
	protected function generate_asset_url( string $url ): string {
		$parsed_query = wp_parse_url( $url, PHP_URL_QUERY );
		$queries      = [];

		if ( $parsed_query ) {
			parse_str( $parsed_query, $queries );
		}

		$queries['wpr_t'] = current_time( 'timestamp' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested

		$cached_url = $this->cache->generate_url( $this->format_url( $url ) );

		$this->logger::debug(
			"Generated url lazy css files $url",
			$this->generate_log_context(
				[
					'data' => $cached_url,
				]
				)
		);

		return add_query_arg( $queries, $cached_url );
	}

	/**
	 * Generate the context for logs.
	 *
	 * @param array $data Data to pass to logs.
	 * @return array
	 */
	protected function generate_log_context( array $data = [] ): array {
		return array_merge(
			$data,
			[
				'type' => 'lazyload_css_bg_images',
			]
			);
	}

	/**
	 * Protect URL inside the content.
	 *
	 * @param string $content Content to protect.
	 * @param array  $css_files CSS files from the content.
	 * @return ProtectedContent
	 */
	protected function protect_css_urls( string $content, array $css_files ): ProtectedContent {
		usort(
			$css_files,
			function ( $url1, $url2 ) {
				return strlen( $url1 ) < strlen( $url2 ) ? 1 : -1;
			}
		);

		$css_files_mapping = [];

		foreach ( $css_files as $url ) {
			$placeholder = uniqid( 'url_bg_css_' );

			$content = str_replace( $url, $placeholder, $content );

			$css_files_mapping[ $url ] = $placeholder;
		}

		return $this->lazyloaded_content_factory->make_protected_content( $css_files_mapping, $content );
	}

	/**
	 * Exclude SVG from lazy loaded images.
	 *
	 * @param array $urls URLs to exclude.
	 * @return array
	 */
	public function remove_svg_from_lazyload_css( array $urls ): array {
		$urls[] = 'data:image/svg+xml';

		return $urls;
	}
}
