<?php
/**
 * Variable replacement functionality.
 *
 * @since      1.0.33
 * @package    RankMath
 * @subpackage RankMath\Replace_Variables
 * @author     Rank Math <support@rankmath.com>
 *
 * @copyright Copyright (C) 2008-2019, Yoast BV
 * The following code is a derivative work of the code from the Yoast(https://github.com/Yoast/wordpress-seo/), which is licensed under GPL v3.
 */

namespace RankMath\Replace_Variables;

use RankMath\Helper;
use RankMath\Helpers\Str;
use RankMath\Paper\Paper;

defined( 'ABSPATH' ) || exit;

/**
 * Replacer class.
 */
class Replacer {

	/**
	 * Do not process the same string over and over again.
	 *
	 * @var array
	 */
	public static $replacements_cache = [];

	/**
	 * Non-cacheable replacements.
	 *
	 * @var array
	 */
	public static $non_cacheable_replacements;

	/**
	 * Default post data.
	 *
	 * @var array
	 */
	public static $defaults = [
		'ID'            => '',
		'name'          => '',
		'post_author'   => '',
		'post_content'  => '',
		'post_date'     => '',
		'post_excerpt'  => '',
		'post_modified' => '',
		'post_title'    => '',
		'taxonomy'      => '',
		'term_id'       => '',
		'term404'       => '',
		'filename'      => '',
	];

	/**
	 * Arguments.
	 *
	 * @var object
	 */
	public static $args;

	/**
	 * Process post content once.
	 *
	 * @var array
	 */
	public static $content_processed = [];

	/**
	 * Exclude variables.
	 *
	 * @var array
	 */
	public $exclude = [];

	/**
	 *  Replace `%variables%` with context-dependent value.
	 *
	 * @param string $string  The string containing the %variables%.
	 * @param array  $args    Context object, can be post, taxonomy or term.
	 * @param array  $exclude Excluded variables won't be replaced.
	 *
	 * @return string
	 */
	public function replace( $string, $args = [], $exclude = [] ) {
		$string = wp_strip_all_tags( $string );

		// Bail early.
		if ( ! Str::contains( '%', $string ) ) {
			return $string;
		}

		if ( Str::ends_with( ' %sep%', $string ) ) {
			$string = substr( $string, 0, -5 );
		}

		$this->pre_replace( $args, $exclude );
		$replacements = $this->set_up_replacements( $string );

		/**
		 * Filter: Allow customizing the replacements.
		 *
		 * @param array $replacements The replacements.
		 * @param array $args The object some of the replacement values might come from,
		 *                    could be a post, taxonomy or term.
		 */
		$replacements = apply_filters( 'rank_math/replacements', $replacements, self::$args );

		// Do the replacements.
		if ( is_array( $replacements ) && [] !== $replacements ) {
			$string = str_replace( array_keys( $replacements ), array_values( $replacements ), $string );
		}

		if ( isset( $replacements['%sep%'] ) && Str::is_non_empty( $replacements['%sep%'] ) ) {
			$q_sep  = preg_quote( $replacements['%sep%'], '`' );
			$string = preg_replace( '`' . $q_sep . '(?:\s*' . $q_sep . ')*`u', $replacements['%sep%'], $string );
		}

		// Remove excess whitespace.
		return preg_replace( '[\s\s+]', ' ', $string );
	}

	/**
	 * Run prior to replacement.
	 *
	 * @param array $args    Context object, can be post, taxonomy or term.
	 * @param array $exclude Excluded variables won't be replaced.
	 */
	private function pre_replace( $args, $exclude ) {
		if ( is_array( $exclude ) ) {
			$this->exclude = $exclude;
		}

		self::$args = (object) array_merge( self::$defaults, (array) $args );
		$this->process_content();
	}

	/**
	 * Process content only once, because it's expensive.
	 *
	 * @return void
	 */
	private function process_content() {
		if ( ! isset( self::$content_processed[ self::$args->ID ]['post_content'] ) ) {
			self::$content_processed[ self::$args->ID ]['post_content'] = Paper::should_apply_shortcode() ? do_shortcode( self::$args->post_content ) : Helper::strip_shortcodes( self::$args->post_content );
			self::$content_processed[ self::$args->ID ]['post_excerpt'] = Paper::should_apply_shortcode() ? do_shortcode( self::$args->post_excerpt ) : Helper::strip_shortcodes( self::$args->post_excerpt );
		}

		self::$args->post_content = self::$content_processed[ self::$args->ID ]['post_content'];
		self::$args->post_excerpt = self::$content_processed[ self::$args->ID ]['post_excerpt'];
	}

	/**
	 * Get the replacements for the variables.
	 *
	 * @param string $string String to parse for variables.
	 *
	 * @return array Retrieved replacements.
	 */
	private function set_up_replacements( $string ) {
		if ( $this->has_cache( $string ) ) {
			return $this->get_cache( $string );
		}

		$replacements = [];
		if ( ! preg_match_all( '/%(([a-z0-9_-]+)\(([^)]*)\)|[^\s]+)%/iu', $string, $matches ) ) {
			$this->set_cache( $string, $replacements );
			return $replacements;
		}

		foreach ( $matches[1] as $index => $variable_id ) {
			$value = $this->get_variable_value( $matches, $index, $variable_id );
			if ( false !== $value ) {
				$replacements[ $matches[0][ $index ] ] = $value;
			}

			unset( $variable );
		}

		$this->set_cache( $string, $replacements );
		return $replacements;
	}

	/**
	 * Get non-cacheable variables.
	 *
	 * @return array
	 */
	private function get_non_cacheable_variables() {
		if ( ! is_null( self::$non_cacheable_replacements ) ) {
			return self::$non_cacheable_replacements;
		}

		$non_cacheable = [];
		foreach ( rank_math()->variables->get_replacements() as $variable ) {
			if ( ! $variable->is_cacheable() ) {
				$non_cacheable[] = $variable->get_id();
			}
		}

		/**
		 * Filter: Allow changing the non-cacheable variables.
		 *
		 * @param array $non_cacheable The non-cacheable variable IDs.
		 */
		self::$non_cacheable_replacements = apply_filters( 'rank_math/replacements/non_cacheable', $non_cacheable );

		return self::$non_cacheable_replacements;
	}

	/**
	 * Check if we have cache for a string.
	 *
	 * @param string $string String to check.
	 *
	 * @return bool
	 */
	private function has_cache( $string ) {
		return isset( self::$replacements_cache[ md5( $string ) ] );
	}

	/**
	 * Get cache for a string. Handles non-cacheable variables.
	 *
	 * @param string $string String to get cache for.
	 *
	 * @return array
	 */
	private function get_cache( $string ) {
		$non_cacheable = $this->get_non_cacheable_variables();
		$replacements  = self::$replacements_cache[ md5( $string ) ];
		if ( empty( $non_cacheable ) ) {
			return $replacements;
		}

		foreach ( $replacements as $key => $value ) {
			$id = explode( '(', trim( $key, '%' ) )[0];
			if ( ! in_array( $id, $non_cacheable, true ) ) {
				continue;
			}

			$var_args = '';
			$parts    = explode( '(', trim( $key, '%)' ) );
			if ( isset( $parts[1] ) ) {
				$var_args = $this->normalize_args( $parts[1] );
			}

			$replacements[ $key ] = $this->get_variable_by_id( $id, $var_args )->run_callback( $var_args, self::$args );
		}

		return $replacements;
	}

	/**
	 * Set cache for a string.
	 *
	 * @param string $string String to set cache for.
	 *
	 * @param array  $cache  Cache to set.
	 */
	private function set_cache( $string, $cache ) {
		self::$replacements_cache[ md5( $string ) ] = $cache;
	}

	/**
	 * Get variable value.
	 *
	 * @param array  $matches Regex matches found in the string.
	 * @param int    $index   Index of the matched.
	 * @param string $id      Variable id.
	 *
	 * @return mixed
	 */
	private function get_variable_value( $matches, $index, $id ) {
		// Don't set up excluded replacements.
		if ( isset( $matches[0][ $index ] ) && in_array( $matches[0][ $index ], $this->exclude, true ) ) {
			return false;
		}

		$has_args = ! empty( $matches[2][ $index ] ) && ! empty( $matches[3][ $index ] );
		$id       = $has_args ? $matches[2][ $index ] : $id;
		$var_args = $has_args ? $this->normalize_args( $matches[3][ $index ] ) : [];
		$variable = $this->get_variable_by_id( $id, $var_args );

		if ( is_null( $variable ) ) {
			return rank_math()->variables->remove_non_replaced ? '' : false;
		}

		return $variable->run_callback( $var_args, self::$args );
	}

	/**
	 * Find variable.
	 *
	 * @param string $id   Variable id.
	 * @param array  $args Array of arguments.
	 *
	 * @return Variable|null
	 */
	private function get_variable_by_id( $id, $args ) {
		if ( ! isset( rank_math()->variables ) ) {
			return null;
		}

		$replacements = rank_math()->variables->get_replacements();
		if ( isset( $replacements[ $id ] ) ) {
			return $replacements[ $id ];
		}

		if ( ! empty( $args ) && isset( $replacements[ $id . '_args' ] ) ) {
			return $replacements[ $id . '_args' ];
		}

		return null;
	}

	/**
	 * Convert arguments string to arguments array.
	 *
	 * @param  string $string The string that needs to be converted.
	 *
	 * @return array
	 */
	private function normalize_args( $string ) {
		$string = wp_specialchars_decode( $string );
		if ( ! Str::contains( '=', $string ) ) {
			return $string;
		}

		return wp_parse_args( $string, [] );
	}
}