<?php
/**
 * Class Sanitization
 *
 * @link      https://github.com/googleforcreators/web-stories-wp
 *
 * @copyright 2020 Google LLC
 * @license   https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
 */

/**
 * Copyright 2020 Google LLC
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

declare(strict_types = 1);

namespace Google\Web_Stories\AMP;

use DOMElement;
use DOMNode;
use Google\Web_Stories\Model\Story;
use Google\Web_Stories\Settings;
use Google\Web_Stories\Story_Post_Type;
use Google\Web_Stories_Dependencies\AMP_Allowed_Tags_Generated;
use Google\Web_Stories_Dependencies\AMP_Content_Sanitizer;
use Google\Web_Stories_Dependencies\AMP_Dev_Mode_Sanitizer;
use Google\Web_Stories_Dependencies\AMP_DOM_Utils;
use Google\Web_Stories_Dependencies\AMP_Layout_Sanitizer;
use Google\Web_Stories_Dependencies\AMP_Script_Sanitizer;
use Google\Web_Stories_Dependencies\AMP_Style_Sanitizer;
use Google\Web_Stories_Dependencies\AmpProject\Amp;
use Google\Web_Stories_Dependencies\AmpProject\Dom\Document;
use Google\Web_Stories_Dependencies\AmpProject\Extension;
use Google\Web_Stories_Dependencies\AmpProject\Html\Attribute;
use Google\Web_Stories_Dependencies\AmpProject\Html\Tag;

/**
 * Sanitization class.
 *
 * Largely copied from AMP_Theme_Support.
 *
 * @since 1.1.0
 *
 * @see \AMP_Theme_Support
 */
class Sanitization {
	/**
	 * Settings instance.
	 *
	 * @var Settings Settings instance.
	 */
	private Settings $settings;

	/**
	 * Analytics constructor.
	 *
	 * @since 1.12.0
	 *
	 * @param Settings $settings    Settings instance.
	 * @return void
	 */
	public function __construct( Settings $settings ) {
		$this->settings = $settings;
	}

	/**
	 * Sanitizes a document.
	 *
	 * @since 1.1.0
	 *
	 * @param Document $document Document instance.
	 */
	public function sanitize_document( Document $document ): void {
		$sanitizers = $this->get_sanitizers();

		$result = AMP_Content_Sanitizer::sanitize_document( $document, $sanitizers, [] );

		$this->ensure_required_markup( $document, $result['scripts'] );
	}

	/**
	 * Validation error callback.
	 *
	 * @since 1.1.0
	 *
	 * @see AMP_Validation_Error_Taxonomy::get_validation_error_sanitization
	 *
	 * @param array{code: string}              $error Error info, especially code.
	 * @param array{node?: DOMElement|DOMNode} $data Additional data, including the node.
	 * @return bool Whether the validation error should be sanitized.
	 */
	public function validation_error_callback( array $error, array $data = [] ): bool { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
		/**
		 * Filters whether the validation error should be sanitized.
		 *
		 * Returning true this indicates that the validation error is acceptable
		 * and should not be considered a blocker to render AMP. Returning null
		 * means that the default status should be used.
		 *
		 * Note that the $node is not passed here to ensure that the filter can be
		 * applied on validation errors that have been stored. Likewise, the $sources
		 * are also omitted because these are only available during an explicit
		 * validation request and so they are not suitable for plugins to vary
		 * sanitization by.
		 *
		 * @since 1.1.0
		 *
		 * @see AMP_Validation_Manager::is_sanitization_auto_accepted() Which controls whether an error is initially accepted or rejected for sanitization.
		 *
		 * @param bool $sanitized Whether the validation error should be sanitized.
		 * @param array $error Validation error being sanitized.
		 */
		return apply_filters( 'web_stories_amp_validation_error_sanitized', true, $error );
	}

	/**
	 * Adds missing scripts.
	 *
	 * @SuppressWarnings(PHPMD)
	 *
	 * @since 1.1.0
	 *
	 * @link https://github.com/ampproject/amp-wp/blob/2.1.3/includes/class-amp-theme-support.php#L1381-L1594
	 * @see \AMP_Theme_Support::ensure_required_markup
	 *
	 * @param Document             $document Document instance.
	 * @param array<string,string> $scripts  List of found scripts.
	 */
	protected function ensure_required_markup( Document $document, array $scripts ): void { // phpcs:ignore SlevomatCodingStandard.Complexity.Cognitive.ComplexityTooHigh
		/**
		 * Link elements.
		 *
		 * @var array{preconnect: \DOMElement[]|null,dns-prefetch: \DOMElement[]|null,preload: \DOMElement[]|null, prerender: \DOMElement[]|null, prefetch: \DOMElement[]|null }
		 */
		$links = [
			Attribute::REL_PRECONNECT => [
				// Include preconnect link for AMP CDN for browsers that don't support preload.
				AMP_DOM_Utils::create_node(
					$document,
					Tag::LINK,
					[
						Attribute::REL  => Attribute::REL_PRECONNECT,
						Attribute::HREF => 'https://cdn.ampproject.org',
					]
				),
			],
		];

		// Obtain the existing AMP scripts.
		$amp_scripts     = [];
		$ordered_scripts = [];
		$head_scripts    = [];
		$runtime_src     = 'https://cdn.ampproject.org/v0.js';

		/**
		 * Script element.
		 *
		 * @var DOMElement $script
		 */
		foreach ( $document->head->getElementsByTagName( Tag::SCRIPT ) as $script ) {
			$head_scripts[] = $script;
		}

		foreach ( $head_scripts as $script ) {
			$src = $script->getAttribute( Attribute::SRC );

			if ( ! $src || ! str_starts_with( $src, 'https://cdn.ampproject.org/' ) ) {
				continue;
			}

			if ( 'v0.js' === substr( $src, - \strlen( 'v0.js' ) ) ) {
				$amp_scripts[ Amp::RUNTIME ] = $script;
			} elseif ( $script->hasAttribute( Attribute::CUSTOM_ELEMENT ) ) {
				$amp_scripts[ $script->getAttribute( Attribute::CUSTOM_ELEMENT ) ] = $script;
			} elseif ( $script->hasAttribute( Attribute::CUSTOM_TEMPLATE ) ) {
				$amp_scripts[ $script->getAttribute( Attribute::CUSTOM_TEMPLATE ) ] = $script;
			}

			// It will be added back further down.
			$document->head->removeChild( $script );
		}

		$specs = $this->get_extension_sources();

		// Create scripts for any components discovered from output buffering that are missing.
		foreach ( array_diff( array_keys( $scripts ), array_keys( $amp_scripts ) ) as $missing_script_handle ) {
			$attrs = [
				Attribute::SRC   => $specs[ $missing_script_handle ],
				Attribute::ASYNC => '',
			];
			if ( Extension::MUSTACHE === $missing_script_handle ) {
				$attrs[ Attribute::CUSTOM_TEMPLATE ] = (string) $missing_script_handle;
			} else {
				$attrs[ Attribute::CUSTOM_ELEMENT ] = (string) $missing_script_handle;
			}

			$amp_scripts[ $missing_script_handle ] = AMP_DOM_Utils::create_node( $document, Tag::SCRIPT, $attrs );
		}

		// Remove scripts that had already been added but couldn't be detected from output buffering.
		$extension_specs            = AMP_Allowed_Tags_Generated::get_extension_specs();
		$superfluous_script_handles = array_diff(
			array_keys( $amp_scripts ),
			[ ...array_keys( $scripts ), Amp::RUNTIME ]
		);

		foreach ( $superfluous_script_handles as $superfluous_script_handle ) {
			if ( ! empty( $extension_specs[ $superfluous_script_handle ]['requires_usage'] ) ) {
				unset( $amp_scripts[ $superfluous_script_handle ] );
			}
		}

		/* phpcs:ignore Squiz.PHP.CommentedOutCode.Found
		 *
		 * "2. Next, preload the AMP runtime v0.js <script> tag with <link as=script href=https://cdn.ampproject.org/v0.js rel=preload>.
		 * The AMP runtime should start downloading as soon as possible because the AMP boilerplate hides the document via body { visibility:hidden }
		 * until the AMP runtime has loaded. Preloading the AMP runtime tells the browser to download the script with a higher priority."
		 * {@link https://amp.dev/documentation/guides-and-tutorials/optimize-and-measure/optimize_amp/ Optimize the AMP Runtime loading}
		 */
		$prioritized_preloads = [];
		if ( ! isset( $links[ Attribute::REL_PRELOAD ] ) ) {
			$links[ Attribute::REL_PRELOAD ] = [];
		}

		$prioritized_preloads[] = AMP_DOM_Utils::create_node(
			$document,
			Tag::LINK,
			[
				Attribute::REL  => Attribute::REL_PRELOAD,
				'as'            => Tag::SCRIPT,
				Attribute::HREF => $runtime_src,
			]
		);

		/*
		 * "3. If your page includes render-delaying extensions (e.g., amp-experiment, amp-dynamic-css-classes, amp-story),
		 * preload those extensions as they're required by the AMP runtime for rendering the page."
		 */
		$amp_script_handles = array_keys( $amp_scripts );
		foreach ( array_intersect( Amp::RENDER_DELAYING_EXTENSIONS, $amp_script_handles ) as $script_handle ) {
			if ( ! \in_array( $script_handle, Amp::RENDER_DELAYING_EXTENSIONS, true ) ) {
				continue;
			}

			/**
			 * AMP script element.
			 *
			 * @var DOMElement $script_element
			 */
			$script_element = $amp_scripts[ $script_handle ];

			$prioritized_preloads[] = AMP_DOM_Utils::create_node(
				$document,
				Tag::LINK,
				[
					Attribute::REL  => Attribute::REL_PRELOAD,
					'as'            => Tag::SCRIPT,
					Attribute::HREF => $script_element->getAttribute( Attribute::SRC ),
				]
			);
		}
		$links[ Attribute::REL_PRELOAD ] = array_merge( $prioritized_preloads, $links[ Attribute::REL_PRELOAD ] );

		// Store the last meta tag as the previous node to append to.
		$meta_tags     = $document->head->getElementsByTagName( Tag::META );
		$previous_node = $meta_tags->length > 0 ? $meta_tags->item( $meta_tags->length - 1 ) : $document->head->firstChild;

		/*
		 * "4. Use preconnect to speedup the connection to other origin where the full resource URL is not known ahead of time,
		 * for example, when using Google Fonts."
		 *
		 * Note that \AMP_Style_Sanitizer::process_link_element() will ensure preconnect links for Google Fonts are present.
		 */
		$link_relations = [ Attribute::REL_PRECONNECT, Attribute::REL_DNS_PREFETCH, Attribute::REL_PRELOAD, Attribute::REL_PRERENDER, Attribute::REL_PREFETCH ];
		foreach ( $link_relations as $rel ) {
			if ( ! isset( $links[ $rel ] ) ) {
				continue;
			}

			/**
			 * Link node.
			 *
			 * @var DOMElement $link
			 */
			foreach ( $links[ $rel ] as $link ) {
				if ( $link->parentNode ) {
					$link->parentNode->removeChild( $link ); // So we can move it.
				}
				if ( $previous_node && $previous_node->nextSibling ) {
					$document->head->insertBefore( $link, $previous_node->nextSibling );
					$previous_node = $link;
				}
			}
		}

		// "5. Load the AMP runtime."
		if ( isset( $amp_scripts[ Amp::RUNTIME ] ) ) {
			$ordered_scripts[ Amp::RUNTIME ] = $amp_scripts[ Amp::RUNTIME ];
			unset( $amp_scripts[ Amp::RUNTIME ] );
		} else {
			$script = $document->createElement( Tag::SCRIPT );
			if ( $script ) {
				$script->setAttribute( Attribute::ASYNC, '' );
				$script->setAttribute( Attribute::SRC, $runtime_src );
				$ordered_scripts[ Amp::RUNTIME ] = $script;
			}
		}

		/*
		 * "6. Specify the <script> tags for render-delaying extensions (e.g., amp-experiment amp-dynamic-css-classes and amp-story"
		 *
		 * {@link https://amp.dev/documentation/guides-and-tutorials/optimize-and-measure/optimize_amp/ AMP Hosting Guide}
		 */
		foreach ( Amp::RENDER_DELAYING_EXTENSIONS as $extension ) {
			if ( isset( $amp_scripts[ $extension ] ) ) {
				$ordered_scripts[ $extension ] = $amp_scripts[ $extension ];
				unset( $amp_scripts[ $extension ] );
			}
		}

		/*
		 * "7. Specify the <script> tags for remaining extensions (e.g., amp-bind ...). These extensions are not render-delaying
		 * and therefore should not be preloaded as they might take away important bandwidth for the initial render."
		 */
		ksort( $amp_scripts );
		$ordered_scripts = array_merge( $ordered_scripts, $amp_scripts );

		/**
		 * Script element.
		 *
		 * @var DOMElement $ordered_script
		 */
		foreach ( $ordered_scripts as $ordered_script ) {
			if ( $previous_node && $previous_node->nextSibling ) {
				$document->head->insertBefore( $ordered_script, $previous_node->nextSibling );
				$previous_node = $ordered_script;
			}
		}
	}

	/**
	 * Returns AMP extension URLs, keyed by extension name.
	 *
	 * @since 1.1.0
	 *
	 * @link https://github.com/ampproject/amp-wp/blob/2.1.3/includes/amp-helper-functions.php#L876-L941
	 * @see amp_register_default_scripts
	 *
	 * @return array<string,string> List of extensions and their URLs.
	 */
	protected function get_extension_sources(): array {
		$specs = [];
		// Register all AMP components as defined in the spec.
		foreach ( AMP_Allowed_Tags_Generated::get_extension_specs() as $extension_name => $extension_spec ) {
			$src = sprintf(
				'https://cdn.ampproject.org/v0/%s-%s.js',
				$extension_name,
				$extension_spec['latest']
			);

			$specs[ $extension_name ] = $src;
		}

		return $specs;
	}

	/**
	 * Determine whether AMP dev mode is enabled.
	 *
	 * When enabled, the <html> element will get the data-ampdevmode attribute and the plugin will add the same attribute
	 * to elements associated with the admin bar and other elements that are provided by the `amp_dev_mode_element_xpaths`
	 * filter.
	 *
	 * @since 1.1.0
	 *
	 * @link https://github.com/ampproject/amp-wp/blob/2.1.3/includes/amp-helper-functions.php#L1296-L1330
	 * @see amp_is_dev_mode
	 *
	 * @return bool Whether AMP dev mode is enabled.
	 */
	protected function is_amp_dev_mode(): bool {
		// For the few sites that forcibly show the admin bar even when the user is logged out, only enable dev
		// mode if the user is actually logged in. This prevents the dev mode from being served to crawlers
		// when they index the AMP version.
		$dev_mode_enabled = ( is_admin_bar_showing() && is_user_logged_in() );

		/**
		 * Filters whether AMP dev mode is enabled.
		 *
		 * When enabled, the data-ampdevmode attribute will be added to the document element and it will allow the
		 * attributes to be added to the admin bar. It will also add the attribute to all elements which match the
		 * queries for the expressions returned by the 'web_stories_amp_dev_mode_element_xpaths' filter.
		 *
		 * @since 1.1.0
		 *
		 * @param bool $dev_mode_enabled Whether AMP dev mode is enabled.
		 */
		return apply_filters( 'web_stories_amp_dev_mode_enabled', $dev_mode_enabled );
	}

	/**
	 * Returns a list of sanitizers to use.
	 *
	 * This is replica of amp_get_content_sanitizers() to avoid
	 * loading amp-helper-functions.php due to side-effects like
	 * accessing options from the database, requiring AMP__VERSION,
	 * and causing conflicts with our own amp_is_request() compat shim.
	 *
	 * @SuppressWarnings(PHPMD.ExcessiveMethodLength)
	 *
	 * @since 1.1.0
	 *
	 * @link https://github.com/ampproject/amp-wp/blob/2.1.3/includes/amp-helper-functions.php#L1332-L1521
	 * @link https://github.com/ampproject/amp-wp/blob/2.1.3/includes/validation/class-amp-validation-manager.php#L1691-L1730
	 * @see amp_get_content_sanitizers
	 * @see AMP_Validation_Manager::filter_sanitizer_args
	 *
	 * @return array<string,array<string,bool|string[]|string>> Sanitizers.
	 */
	protected function get_sanitizers(): array {
		// This fallback to get_permalink() ensures that there's a canonical link
		// even when previewing drafts.
		$canonical_url = wp_get_canonical_url();
		if ( ! $canonical_url ) {
			$canonical_url = get_permalink();
		}

		$sanitizers = [
			AMP_Script_Sanitizer::class        => [],
			AMP_Style_Sanitizer::class         => [

				/*
				 * @todo Enable by default and allow filtering once AMP_Style_Sanitizer does not call AMP_Options_Manager
				 *       which in turn requires AMP__VERSION to be defined.
				 */
				'allow_transient_caching'   => false,
				'use_document_element'      => true,
				'dynamic_element_selectors' => [
					'amp-story-captions',
				],
			],
			Meta_Sanitizer::class              => [],
			AMP_Layout_Sanitizer::class        => [],
			Canonical_Sanitizer::class         => [
				'canonical_url' => $canonical_url,
			],
			Tag_And_Attribute_Sanitizer::class => [],
		];

		$post = get_queried_object();

		if ( $post instanceof \WP_Post && Story_Post_Type::POST_TYPE_SLUG === get_post_type( $post ) ) {
			$video_cache_enabled = (bool) $this->settings->get_setting( $this->settings::SETTING_NAME_VIDEO_CACHE );

			$story = new Story();
			$story->load_from_post( $post );

			$poster_images = [
				'poster-portrait-src' => esc_url_raw( $story->get_poster_portrait() ),
			];

			$sanitizers[ Story_Sanitizer::class ] = [
				'publisher_logo' => (string) $story->get_publisher_logo_url(),
				'publisher'      => $story->get_publisher_name(),
				'poster_images'  => array_filter( $poster_images ),
				'video_cache'    => $video_cache_enabled,
				'title_tag'      => wp_get_document_title(),
				'description'    => wp_strip_all_tags( get_the_excerpt() ),
			];
		}

		/**
		 * Filters the content sanitizers.
		 *
		 * @since 1.1.0
		 *
		 * @param array  $sanitizers Sanitizers.
		 */
		$sanitizers = apply_filters( 'web_stories_amp_sanitizers', $sanitizers );

		if ( $this->is_amp_dev_mode() ) {
			/**
			 * Filters the XPath queries for elements that should be enabled for dev mode.
			 *
			 * By supplying XPath queries to this filter, the data-ampdevmode attribute will automatically be added to the
			 * root HTML element as well as to any elements that match the expressions. The attribute is added to the
			 * elements prior to running any of the sanitizers.
			 *
			 * @since 1.3
			 *
			 * @param string[] $element_xpaths XPath element queries. Context is the root element.
			 */
			$dev_mode_xpaths = apply_filters( 'web_stories_amp_dev_mode_element_xpaths', [] );

			if ( is_admin_bar_showing() ) {
				$dev_mode_xpaths[] = '//*[ @id = "wpadminbar" ]';
				$dev_mode_xpaths[] = '//*[ @id = "wpadminbar" ]//*';
				$dev_mode_xpaths[] = '//style[ @id = "admin-bar-inline-css" ]';
			}

			$sanitizers = array_merge(
				[
					AMP_Dev_Mode_Sanitizer::class => [
						'element_xpaths' => $dev_mode_xpaths,
					],
				],
				$sanitizers
			);
		}

		// Force certain sanitizers to be at end.
		// AMP_Style_Sanitizer needs to catch any CSS changes from previous sanitizers.
		// Tag_And_Attribute_Sanitizer must come at the end to clean up any remaining issues the other sanitizers didn't catch.
		foreach ( [
			AMP_Layout_Sanitizer::class,
			AMP_Style_Sanitizer::class,
			Meta_Sanitizer::class,
			Tag_And_Attribute_Sanitizer::class,
		] as $class_name ) {
			if ( isset( $sanitizers[ $class_name ] ) ) {
				$sanitizer = $sanitizers[ $class_name ];
				unset( $sanitizers[ $class_name ] );
				$sanitizers[ $class_name ] = $sanitizer;
			}
		}

		foreach ( $sanitizers as &$sanitizer ) {
			$sanitizer['validation_error_callback'] = [ $this, 'validation_error_callback' ];
		}

		unset( $sanitizer );

		return $sanitizers;
	}
}
