<?php
namespace AIOSEO\Plugin\Addon\ImageSeo\Image;

// Exit if accessed directly.
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Adds support for Image SEO.
 *
 * @since 1.0.0
 */
class Image {
	/**
	 * The supported image extensions.
	 *
	 * @since 1.1.6
	 *
	 * @var array
	 */
	private $supportedExtensions = [ 'png', 'jpg', 'jpeg', 'gif', 'heic', 'svg', 'ico', 'webp' ];

	/**
	 * Class constructor.
	 *
	 * @since 1.0.0
	 */
	public function __construct() {
		// Filter images embedded into the post content or 3rd party plugins.
		add_filter( 'the_content', [ $this, 'filterContent' ], 99999 );
		add_filter( 'seedprod_lpage_content', [ $this, 'filterContent' ] );
		add_filter( 'woocommerce_short_description', [ $this, 'filterContent' ] );

		// Filter images on attachment pages.
		add_filter( 'wp_get_attachment_image_attributes', [ $this, 'filterImageAttributes' ], 10, 2 );

		// Filter attachment data on upload.
		add_filter( 'wp_insert_attachment_data', [ $this, 'filterImageData' ], 10, 2 );
		add_filter( 'wp_unique_filename', [ $this, 'filterFilename' ], 20, 2 );

		// Filter embedded attachment caption and description smart tags on-the-fly.
		add_action( 'template_redirect', [ $this, 'parseEmbeddedImageSmartTags' ] );

		// Bulk actions.
		add_filter( 'bulk_actions-upload', [ $this, 'registerBulkActions' ] );
		add_filter( 'handle_bulk_actions-upload', [ $this, 'handleBulkActions' ], 10, 3 );
	}

	/**
	 * Filters the content of the requested post.
	 *
	 * @since 1.0.0
	 *
	 * @param  string $content The post content.
	 * @return string          The filtered post content.
	 */
	public function filterContent( $content ) {
		if ( empty( $content ) || is_admin() || ! is_singular() ) {
			return $content;
		}

		$doc      = new \DOMDocument( '1.0', 'UTF-8' );
		$encoding = '<?xml encoding="utf-8" ?>';
		@$doc->loadHTML( $encoding . $content );

		$images = $doc->getElementsByTagName( 'img' );

		if ( empty( $images->length ) ) {
			return $content;
		}

		foreach ( $images as $img ) {
			if ( ! $img instanceof \DOMElement ) {
				continue;
			}

			$parent = $img->parentNode;
			if ( ! $parent instanceof \DOMElement ) {
				continue;
			}

			$isLink  = 'a' === $parent->tagName;
			$caption = null;

			if ( $isLink ) {
				$parent = $parent->parentNode;
			}

			if ( ! empty( $parent ) && $parent instanceof \DOMElement && 'figure' === $parent->tagName ) {
				$caption = ! empty( $parent->getElementsByTagName( 'figcaption' ) ) ? $parent->getElementsByTagName( 'figcaption' )->item( 0 ) : null;
			}

			if ( ! $img && ! $caption ) {
				continue;
			}

			$this->filterEmbeddedImages( $img, $caption );
		}

		return str_replace( $encoding, '', $doc->saveHTML() );
	}

	/**
	 * Filters the attributes of image attachment pages.
	 *
	 * @since 1.0.0
	 *
	 * @param  array    $attributes The image attributes.
	 * @param  \WP_Post $post       The post object.
	 * @return array                The filtered image attributes
	 */
	public function filterImageAttributes( $attributes, $post = null ) {
		if ( is_admin() || ! is_singular() ) {
			return $attributes;
		}

		$attributes['title'] = $this->getAttribute( 'title', $post->ID );
		$attributes['alt']   = $this->getAttribute( 'altTag', $post->ID );

		return $attributes;
	}

	/**
	 * Filters the attributes of images that are embedded in the post content.
	 *
	 * @since 1.0.0
	 *
	 * @param  \DOMElement      $image   The image element.
	 * @param  \DOMElement|null $caption The figcaption element.
	 * @return void
	 */
	public function filterEmbeddedImages( $image, $caption ) {
		$id = $this->imageId( $image );

		if ( ! $id ) {
			return;
		}

		if ( ! $this->isExcluded( 'title' ) ) {
			$title = $image->getAttribute( 'title' );
			$image->setAttribute( 'title', $this->getAttribute( 'title', $id, $title ) );
		}

		if ( ! $this->isExcluded( 'altTag' ) ) {
			$altTag = $image->getAttribute( 'alt' );
			$image->setAttribute( 'alt', $this->getAttribute( 'altTag', $id, $altTag ) );
		}

		if ( ! empty( $caption ) ) {
			$parsedCaption = aioseoImageSeo()->tags->replaceTags( $caption->nodeValue, $id, 'caption' );
			$caption->nodeValue = $parsedCaption;
		}

		return;
	}

	/**
	 * Tries to extract the attachment page ID of an image.
	 *
	 * @since 1.0.0
	 *
	 * @param  \DOMElement $image The image element.
	 * @return mixed              The ID of the attachment page or false if no ID could be found.
	 */
	private function imageId( $image ) {
		$imageId = false;

		if ( $image->hasAttribute( 'class' ) ) {
			$classes = $image->getAttribute( 'class' );

			// Check if class contains an ID.
			if ( preg_match( '#wp-image-(\d+)#', $classes, $matches ) ) {
				$imageId = intval( $matches[1] );
			}

			// Check for SeedProd image.
			if ( ! $imageId && preg_match( '#sp-image-block-([a-z0-9]+)#', $classes, $matches ) ) {
				$imageId = intval( $matches[1] );
			}
		}

		// Allow WPML to find the translated attachment page.
		if ( aioseo()->helpers->isWpmlActive() ) {
			$imageId = apply_filters( 'wpml_object_id', $imageId, 'attachment', true );
		}

		return $imageId;
	}

	/**
	 * Returns the value of a given image attribute.
	 *
	 * @since 1.0.0
	 *
	 * @param  string $attributeName The attribute name.
	 * @param  int    $id            The attachment page ID.
	 * @param  string $value         The value, if it already exists.
	 * @return string                The attribute value.
	 */
	private function getAttribute( $attributeName, $id, $value = '' ) {
		$format = aioseo()->options->image->$attributeName->format;

		if ( $value ) {
			// If the value already exists on the image (e.g. alt text on Image Block), use that to replace the relevant tag in the format.
			$tag    = 'title' === $attributeName ? '#image_title' : '#alt_tag';
			$format = aioseo()->helpers->pregReplace( "/$tag/", $value, $format );

			// Because some HTML entities are already decoded, we must decode them so that we can strip them.
			$format = aioseo()->helpers->decodeHtmlEntities( $format );
		}

		$attribute = aioseoImageSeo()->tags->replaceTags( $format, $id, $attributeName );

		$snakeName = aioseo()->helpers->toSnakeCase( $attributeName );

		return apply_filters( "aioseo_image_seo_$snakeName", $attribute, [ $id ] );
	}

	/**
	 * Filter image caption and description data.
	 *
	 * @since 1.1.0
	 *
	 * @param  array $processedData   An array of slashed, sanitized, and processed attachment image post data.
	 * @param  array $unprocessedData An array of slashed and sanitized attachment post data, but not processed.
	 * @param  bool  $bulk            Whether a bulk action is triggering this method.
	 * @return array                  An array of slashed, sanitized, modified and processed attachment image post data.
	 */
	public function filterImageData( $processedData, $unprocessedData = [], $bulk = false ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
		// If the attachment is not an image, return.
		if ( ! strstr( $processedData['post_mime_type'], 'image/' ) ) {
			return $processedData;
		}

		// If the post already has an ID, an existing attachment is being updated.
		// In that case, we don't want to update the caption/description (only when uploading new images), except if we're running the bulk action.
		if ( ! $bulk && ! empty( $unprocessedData['ID'] ) ) {
			return $processedData;
		}

		if ( $bulk || aioseo()->options->image->caption->autogenerate ) {
			$processedData['post_excerpt'] = aioseo()->options->image->caption->format;
		}

		if ( $bulk || aioseo()->options->image->description->autogenerate ) {
			$processedData['post_content'] = aioseo()->options->image->description->format;
		}

		if ( ! $bulk ) {
			// Harmonize the attachment post title/name with the filename after it's been sanitized.
			$processedData['post_title'] = $this->filterFilenameHelper( $processedData['post_title'] );
			$processedData['post_name']  = $this->filterFilenameHelper( $processedData['post_name'] );
		}

		return $processedData;
	}

	/**
	 * Filters the filename of new images when uploaded.
	 *
	 * @since 1.1.0
	 *
	 * @param  string $filename  The filename.
	 * @param  string $extension The file extension.
	 * @return string            Modified uploaded file data.
	 */
	public function filterFilename( $filename, $extension = '' ) {
		if (
			// Ignore files that are not images.
			! $this->isImage( $filename, $extension ) ||
			(
				// Don't mess with BuddyPress avatar uploads.
				doing_action( 'wp_ajax_bp_avatar_upload' ) || doing_action( 'wp_ajax_bp_avatar_set' )
			)
		) {
			return $filename;
		}

		// First, remove the extension part of the filename temporarily so that we don't mess with it.
		// We'll add it back at the end.
		$filename = preg_replace( "/{$extension}/i", '', (string) $filename );

		$filename = $this->filterFilenameHelper( $filename );

		// Now, add back the extension.
		$filename .= $extension;

		return $filename;
	}

	/**
	 * Helper method for filterFileanmeHelper().
	 *
	 * @since 1.1.1
	 *
	 * @param  string $string The string.
	 * @return string         The filtered string.
	 */
	private function filterFilenameHelper( $string ) {
		$words    = aioseo()->options->image->filename->wordsToStrip;
		$casing   = aioseo()->options->image->filename->casing;

		// First, strip all words that are on the blacklist.
		if ( ! empty( $words ) ) {
			$words = explode( "\n", $words );

			foreach ( $words as $word ) {
				$escapedWord = preg_quote( (string) $word );
				// Strip the word, but not if it's part of another, longer word.
				$string = preg_replace( "/(\b|\_|[0-9])({$escapedWord})(\b|\_|[0-9])/i", '$1$3', (string) $string );
			}
		}

		// Next, change the casing.
		if ( ! empty( $casing ) ) {
			$string = aioseo()->helpers->convertCase( $string, $casing );
		}

		// Finally, strip the punctuation.
		if ( aioseo()->options->image->filename->stripPunctuation ) {
			$string = aioseoImageSeo()->helpers->stripPunctuation( $string, 'filename' );
		}

		return ltrim( $string, '-' );
	}

	/**
	 * Checks whether the given file is an image.
	 *
	 * @since 1.1.6
	 *
	 * @param  string $filename  The filename.
	 * @param  string $extension The file extension.
	 * @return bool              Whether the file is an image.
	 */
	private function isImage( $filename, $extension = '' ) {
		if ( ! $extension ) {
			preg_match( '/.*\.(.*?)$/', (string) $filename, $matches );
			if ( ! empty( $matches[1] ) ) {
				$extension = $matches[1];
			}
		}

		return in_array( ltrim( strtolower( $extension ), '.' ), $this->supportedExtensions, true );
	}

	/**
	 * Parses the attachment caption and description when they are loaded.
	 *
	 * @since 1.1.0
	 *
	 * @return void
	 */
	public function parseEmbeddedImageSmartTags() {
		if ( false !== stripos( strval( get_post_mime_type() ), 'image/' ) ) {
			// phpcs:disable Squiz.NamingConventions.ValidVariableName
			global $wp_query;

			$wp_query->post->post_content = aioseoImageSeo()->tags->replaceTags(
				$wp_query->post->post_content,
				$wp_query->post->ID,
				'description'
			);

			$wp_query->post->post_excerpt = aioseoImageSeo()->tags->replaceTags(
				$wp_query->post->post_excerpt,
				$wp_query->post->ID,
				'caption'
			);
			// phpcs:enable Squiz.NamingConventions.ValidVariableName
		}
	}

	/**
	 * Register the bulk action.
	 *
	 * @since 1.1.0
	 *
	 * @param  array $bulkActions List of bulk actions.
	 * @return array              Modified list of bulk actions.
	 */
	public function registerBulkActions( $bulkActions ) {
		$bulkActions['autogenerate_attributes'] = __( 'Autogenerate image attributes', 'aioseo-image-seo' );

		return $bulkActions;
	}

	/**
	 * Handle bulk actions.
	 *
	 * @since 1.1.0
	 *
	 * @param  string $sendBack   The redirect URL.
	 * @param  string $actionName The action being taken.
	 * @param  array  $posts      The items the bulk action is being applied to.
	 * @return string             The redirect URL.
	 */
	public function handleBulkActions( $sendBack, $actionName = '', $posts = [] ) {
		if ( 'autogenerate_attributes' !== $actionName ) {
			return $sendBack;
		}

		foreach ( $posts as $id ) {
			$post = get_post( $id, ARRAY_A );
			if ( empty( $post['post_type'] ) || 'attachment' !== $post['post_type'] ) {
				continue;
			}

			$filteredData = $this->filterImageData( $post, null, true );
			wp_update_post( $filteredData );
		}

		return $sendBack;
	}

	/**
	 * Check if the post or term should be excluded.
	 *
	 * @since 1.1.0
	 *
	 * @param  string $attributeName Image attribute name eg. title, alt...
	 * @return bool                  Whether the post/term is excluded or not.
	 */
	private function isExcluded( $attributeName ) {
		$postId        = get_the_ID();
		$excludedPosts = aioseo()->options->image->{$attributeName}->advancedSettings->excludePosts;

		if ( ! $postId ) {
			return false;
		}

		foreach ( $excludedPosts as $p ) {
			$post = json_decode( $p );

			if ( $post->value === $postId ) {
				return true;
			}
		}

		$excludedTermIds = [];
		$excludedTerms   = aioseo()->options->image->$attributeName->advancedSettings->excludeTerms;
		foreach ( $excludedTerms as $t ) {
			$term = json_decode( $t );

			if ( is_object( $term ) ) {
				$excludedTermIds[] = (int) $term->value;
			}
		}

		// Check if there is at least one excluded term assigned to the post.
		$excludedTermRelationships = [];
		if ( count( $excludedTermIds ) ) {
			$excludedTermRelationships = aioseo()->core->db->start( 'term_relationships' )
				->select( 'object_id' )
				->where( 'object_id =', $postId )
				->whereIn( 'term_taxonomy_id', $excludedTermIds )
				->limit( 1 )
				->run()
				->result();
		}

		return ! empty( $excludedTermRelationships );
	}
}