<?php
/**
 * Replace Variables Manager
 *
 * Registers and replaces %variables% in SEO template strings.
 *
 * @package SEOvaultSEO
 */

namespace SEOvaultSEO\ReplaceVariables;

use SEOvaultSEO\Core\PostMeta;
use SEOvaultSEO\Core\Options;
use SEOvaultSEO\Utils\Formatter;

defined( 'ABSPATH' ) || exit;

/**
 * Manager class — handles variable registration and replacement.
 */
class Manager {

	/**
	 * Singleton instance.
	 *
	 * @var Manager|null
	 */
	private static $instance = null;

	/**
	 * Registered variables.
	 *
	 * @var Variable[]
	 */
	protected $replacements = array();

	/**
	 * In-memory value cache.
	 *
	 * @var array
	 */
	protected $cache = array();

	/**
	 * Replacement result cache (per string).
	 *
	 * @var array
	 */
	protected static $replacements_cache = array();

	/**
	 * Whether to remove non-replaced variables from output.
	 *
	 * @var bool
	 */
	public $remove_non_replaced = true;

	/**
	 * Current context args (post object, term, etc.).
	 *
	 * @var object
	 */
	public $args;

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

	/**
	 * Whether setup has run.
	 *
	 * @var bool
	 */
	private $is_setup = false;

	/**
	 * Whether we're on a post edit screen.
	 *
	 * @var bool
	 */
	protected $is_post_edit = false;

	/**
	 * Whether we're on a term edit screen.
	 *
	 * @var bool
	 */
	protected $is_term_edit = false;

	/**
	 * Get singleton.
	 *
	 * @return Manager
	 */
	public static function get_instance() {
		if ( null === self::$instance ) {
			self::$instance = new self();
		}

		return self::$instance;
	}

	/**
	 * Constructor — hooks setup to the right action.
	 */
	private function __construct() {
		$action   = is_admin() ? 'admin_enqueue_scripts' : 'wp';
		$priority = is_admin() ? 5 : 25;
		add_action( $action, array( $this, 'setup' ), $priority );
	}

	/**
	 * Set up all replacement variables.
	 *
	 * @return void
	 */
	public function setup() {
		if ( $this->is_setup ) {
			return;
		}

		$this->is_setup = true;

		// Detect admin context.
		if ( is_admin() && function_exists( 'get_current_screen' ) ) {
			$screen = get_current_screen();
			if ( $screen instanceof \WP_Screen ) {
				$this->is_post_edit = 'post' === $screen->base;
				$this->is_term_edit = 'term' === $screen->base;
			}
		}

		/**
		 * Filter: control whether non-replaced variables are stripped.
		 *
		 * @param bool $remove
		 */
		$this->remove_non_replaced = apply_filters( 'seovaultseo/vars/remove_nonreplaced', true );

		$this->setup_basic_variables();
		$this->setup_post_variables();
		$this->setup_term_variables();
		$this->setup_author_variables();
		$this->setup_advanced_variables();

		/**
		 * Action: register extra replacement variables.
		 */
		do_action( 'seovaultseo/vars/register_extra' );
	}

	// =========================================================================
	// Registration
	// =========================================================================

	/**
	 * Register a replacement variable.
	 *
	 * @param string        $id       Unique id (alphanumeric, underscore, dash).
	 * @param array         $args     Definition: name, description, variable, example, nocache.
	 * @param callable|bool $callback Callback returning the replacement value.
	 * @return bool
	 */
	public function register_replacement( $id, $args = array(), $callback = false ) {
		if ( ! preg_match( '`^[A-Z0-9_-]+$`i', $id ) ) {
			return false;
		}

		if ( isset( $this->replacements[ $id ] ) ) {
			return false;
		}

		$variable = Variable::from( $id, $args );
		if ( ! $variable->get_id() ) {
			return false;
		}

		$variable->set_callback( $callback );
		$this->replacements[ $id ] = $variable;

		return true;
	}

	/**
	 * Get all registered replacements.
	 *
	 * @return Variable[]
	 */
	public function get_replacements() {
		return $this->replacements;
	}

	/**
	 * Get variables as array for JSON output (admin picker).
	 *
	 * @return array
	 */
	public function get_variables_for_js() {
		$json = array();
		foreach ( $this->replacements as $id => $variable ) {
			$json[ $id ] = $variable->to_array();
		}

		return apply_filters( 'seovaultseo/vars/replacements_js', $json );
	}

	// =========================================================================
	// Replacement Engine
	// =========================================================================

	/**
	 * Replace %variables% in a string.
	 *
	 * @param string $string  String containing %variables%.
	 * @param array  $args    Context data (post, term, etc.).
	 * @param array  $exclude Variables to exclude from replacement.
	 * @return string
	 */
	public function replace( $string, $args = array(), $exclude = array() ) {
		if ( empty( $string ) ) {
			return '';
		}

		// Ensure variables are registered even when this singleton was created
		// after the wp/admin_enqueue_scripts hook already ran.
		$this->setup();

		$string = wp_strip_all_tags( $string );

		if ( false === strpos( $string, '%' ) ) {
			return $string;
		}

		// Strip trailing separator.
		if ( substr( $string, -6 ) === ' %sep%' ) {
			$string = substr( $string, 0, -6 );
		}

		$this->prepare_args( $args, $exclude );
		$replacements = $this->collect_replacements( $string );

		/**
		 * Filter: modify the replacements before applying.
		 *
		 * @param array  $replacements Key-value pairs.
		 * @param object $args         Context object.
		 */
		$replacements = apply_filters( 'seovaultseo/replacements', $replacements, $this->args );

		if ( is_array( $replacements ) && ! empty( $replacements ) ) {
			$string = str_replace( array_keys( $replacements ), array_values( $replacements ), $string );
		}

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

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

	/**
	 * Prepare context args before replacement.
	 *
	 * @param array $args    Context data.
	 * @param array $exclude Variables to exclude.
	 * @return void
	 */
	protected function prepare_args( $args, $exclude ) {
		if ( empty( $args ) ) {
			$args = $this->get_default_args();
		}

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

	/**
	 * Get default args from current query.
	 *
	 * @return array
	 */
	protected function get_default_args() {
		$post_id = get_queried_object_id();
		$post    = $post_id ? get_post( $post_id ) : null;

		if ( $post ) {
			return array(
				'ID'            => $post->ID,
				'post_author'   => $post->post_author,
				'post_content'  => $post->post_content,
				'post_date'     => $post->post_date,
				'post_excerpt'  => $post->post_excerpt,
				'post_modified' => $post->post_modified,
				'post_title'    => $post->post_title,
				'post_type'     => $post->post_type,
			);
		}

		return self::$defaults;
	}

	/**
	 * Find and collect all replacements for a string.
	 *
	 * @param string $string String to parse.
	 * @return array Key-value map of %token% => value.
	 */
	protected function collect_replacements( $string ) {
		$cache_key = md5(
			$string
			. serialize( (array) $this->args )
			. (string) get_option( 'blogname' )
			. (string) get_option( 'blogdescription' )
		);
		if ( isset( self::$replacements_cache[ $cache_key ] ) ) {
			return self::$replacements_cache[ $cache_key ];
		}

		$replacements = array();

		// Match %variable% or %variable(args)%.
		if ( ! preg_match_all( '/%(([a-z0-9_-]+)\(([^)]*)\)|[^\s]+)%/iu', $string, $matches ) ) {
			self::$replacements_cache[ $cache_key ] = $replacements;
			return $replacements;
		}

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

		self::$replacements_cache[ $cache_key ] = $replacements;
		return $replacements;
	}

	/**
	 * Resolve a single variable match to its value.
	 *
	 * @param array  $matches Regex matches.
	 * @param int    $index   Match index.
	 * @param string $id      Variable id.
	 * @return mixed String value, or false to skip.
	 */
	protected function resolve_variable( $matches, $index, $id ) {
		$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->replacements[ $id ] ?? null;
		if ( ! $variable ) {
			// Try with _args suffix for parameterized variants.
			if ( $has_args && isset( $this->replacements[ $id . '_args' ] ) ) {
				$variable = $this->replacements[ $id . '_args' ];
			}

			if ( ! $variable ) {
				return $this->remove_non_replaced ? '' : false;
			}
		}

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

	/**
	 * Normalize variable arguments string.
	 *
	 * @param string $arg_string Raw argument string from inside parentheses.
	 * @return mixed String or parsed array.
	 */
	protected function normalize_args( $arg_string ) {
		$arg_string = wp_specialchars_decode( $arg_string );
		if ( false === strpos( $arg_string, '=' ) ) {
			return $arg_string;
		}

		return wp_parse_args( $arg_string, array() );
	}

	// =========================================================================
	// Basic Variables
	// =========================================================================

	/**
	 * Register basic site-wide variables.
	 *
	 * @return void
	 */
	protected function setup_basic_variables() {
		$this->register_replacement(
			'sep',
			array(
				'name'        => __( 'Separator', 'seovault-ai' ),
				'description' => __( 'Separator character, as set in settings.', 'seovault-ai' ),
				'variable'    => 'sep',
				'example'     => $this->get_sep(),
			),
			array( $this, 'get_sep' )
		);

		$this->register_replacement(
			'sitename',
			array(
				'name'        => __( 'Site Title', 'seovault-ai' ),
				'description' => __( 'Title of the site.', 'seovault-ai' ),
				'variable'    => 'sitename',
				'example'     => $this->get_sitename(),
			),
			array( $this, 'get_sitename' )
		);

		$this->register_replacement(
			'sitedesc',
			array(
				'name'        => __( 'Site Description', 'seovault-ai' ),
				'description' => __( 'Description of the site.', 'seovault-ai' ),
				'variable'    => 'sitedesc',
				'example'     => $this->get_sitedesc(),
			),
			array( $this, 'get_sitedesc' )
		);

		$this->register_replacement(
			'currentdate',
			array(
				'name'        => __( 'Current Date', 'seovault-ai' ),
				'description' => __( 'Current server date.', 'seovault-ai' ),
				'variable'    => 'currentdate',
				'example'     => $this->get_current_date(),
			),
			array( $this, 'get_current_date' )
		);

		$this->register_replacement(
			'currentday',
			array(
				'name'        => __( 'Current Day', 'seovault-ai' ),
				'description' => __( 'Current server day.', 'seovault-ai' ),
				'variable'    => 'currentday',
				'example'     => $this->get_current_day(),
			),
			array( $this, 'get_current_day' )
		);

		$this->register_replacement(
			'currentmonth',
			array(
				'name'        => __( 'Current Month', 'seovault-ai' ),
				'description' => __( 'Current server month.', 'seovault-ai' ),
				'variable'    => 'currentmonth',
				'example'     => $this->get_current_month(),
			),
			array( $this, 'get_current_month' )
		);

		$this->register_replacement(
			'currentyear',
			array(
				'name'        => __( 'Current Year', 'seovault-ai' ),
				'description' => __( 'Current server year.', 'seovault-ai' ),
				'variable'    => 'currentyear',
				'example'     => $this->get_current_year(),
			),
			array( $this, 'get_current_year' )
		);

		$this->register_replacement(
			'currenttime',
			array(
				'name'        => __( 'Current Time', 'seovault-ai' ),
				'description' => __( 'Current server time.', 'seovault-ai' ),
				'variable'    => 'currenttime',
				'example'     => $this->get_current_time(),
			),
			array( $this, 'get_current_time' )
		);

		$this->register_replacement(
			'currenttime_args',
			array(
				'name'        => __( 'Current Time (advanced)', 'seovault-ai' ),
				'description' => __( 'Current server time with custom format.', 'seovault-ai' ),
				'variable'    => 'currenttime(F jS, Y)',
				'example'     => $this->get_current_time( 'F jS, Y' ),
			),
			array( $this, 'get_current_time' )
		);

		$this->register_replacement(
			'search_query',
			array(
				'name'        => __( 'Search Query', 'seovault-ai' ),
				'description' => __( 'Search query (only on search results page).', 'seovault-ai' ),
				'variable'    => 'search_query',
				'example'     => __( 'example search', 'seovault-ai' ),
			),
			array( $this, 'get_search_query' )
		);
	}

	// =========================================================================
	// Post Variables
	// =========================================================================

	/**
	 * Register post-related variables.
	 *
	 * @return void
	 */
	protected function setup_post_variables() {
		$this->register_replacement(
			'title',
			array(
				'name'        => __( 'Post Title', 'seovault-ai' ),
				'description' => __( 'Title of the current post/page.', 'seovault-ai' ),
				'variable'    => 'title',
				'example'     => $this->get_title(),
				'nocache'     => true,
			),
			array( $this, 'get_title' )
		);

		$this->register_replacement(
			'parent_title',
			array(
				'name'        => __( 'Parent Page Title', 'seovault-ai' ),
				'description' => __( 'Title of the parent page.', 'seovault-ai' ),
				'variable'    => 'parent_title',
				'example'     => $this->get_parent_title(),
			),
			array( $this, 'get_parent_title' )
		);

		$this->register_replacement(
			'excerpt',
			array(
				'name'        => __( 'Post Excerpt', 'seovault-ai' ),
				'description' => __( 'Excerpt of the current post (auto-generated if missing).', 'seovault-ai' ),
				'variable'    => 'excerpt',
				'example'     => $this->get_excerpt(),
				'nocache'     => true,
			),
			array( $this, 'get_excerpt' )
		);

		$this->register_replacement(
			'excerpt_only',
			array(
				'name'        => __( 'Post Excerpt (only)', 'seovault-ai' ),
				'description' => __( 'Excerpt of the current post (without auto-generation).', 'seovault-ai' ),
				'variable'    => 'excerpt_only',
				'example'     => $this->get_excerpt_only(),
				'nocache'     => true,
			),
			array( $this, 'get_excerpt_only' )
		);

		$this->register_replacement(
			'seo_title',
			array(
				'name'        => __( 'SEO Title', 'seovault-ai' ),
				'description' => __( 'Custom or generated SEO title of the current post.', 'seovault-ai' ),
				'variable'    => 'seo_title',
				'example'     => $this->get_seo_title(),
				'nocache'     => true,
			),
			array( $this, 'get_seo_title' )
		);

		$this->register_replacement(
			'seo_description',
			array(
				'name'        => __( 'SEO Description', 'seovault-ai' ),
				'description' => __( 'Custom or generated SEO description of the current post.', 'seovault-ai' ),
				'variable'    => 'seo_description',
				'example'     => $this->get_seo_description(),
				'nocache'     => true,
			),
			array( $this, 'get_seo_description' )
		);

		$this->register_replacement(
			'url',
			array(
				'name'        => __( 'Post URL', 'seovault-ai' ),
				'description' => __( 'URL of the current post/page.', 'seovault-ai' ),
				'variable'    => 'url',
				'example'     => $this->get_url(),
			),
			array( $this, 'get_url' )
		);

		$this->register_replacement(
			'post_thumbnail',
			array(
				'name'        => __( 'Post Thumbnail', 'seovault-ai' ),
				'description' => __( 'Featured image URL of the current post.', 'seovault-ai' ),
				'variable'    => 'post_thumbnail',
				'example'     => $this->get_post_thumbnail(),
				'nocache'     => true,
			),
			array( $this, 'get_post_thumbnail' )
		);

		// Date variables.
		$this->register_replacement(
			'date',
			array(
				'name'        => __( 'Date Published', 'seovault-ai' ),
				'description' => __( 'Publication date of the current post.', 'seovault-ai' ),
				'variable'    => 'date',
				'example'     => $this->get_date(),
				'nocache'     => true,
			),
			array( $this, 'get_date' )
		);

		$this->register_replacement(
			'modified',
			array(
				'name'        => __( 'Date Modified', 'seovault-ai' ),
				'description' => __( 'Last modification date of the current post.', 'seovault-ai' ),
				'variable'    => 'modified',
				'example'     => $this->get_modified(),
				'nocache'     => true,
			),
			array( $this, 'get_modified' )
		);

		$this->register_replacement(
			'date_args',
			array(
				'name'        => __( 'Date Published (advanced)', 'seovault-ai' ),
				'description' => __( 'Publish date with custom format.', 'seovault-ai' ),
				'variable'    => 'date(F jS, Y)',
				'example'     => $this->get_date( 'F jS, Y' ),
			),
			array( $this, 'get_date' )
		);

		$this->register_replacement(
			'modified_args',
			array(
				'name'        => __( 'Date Modified (advanced)', 'seovault-ai' ),
				'description' => __( 'Modified date with custom format.', 'seovault-ai' ),
				'variable'    => 'modified(F jS, Y)',
				'example'     => $this->get_modified( 'F jS, Y' ),
			),
			array( $this, 'get_modified' )
		);

		// Category variables.
		$this->register_replacement(
			'category',
			array(
				'name'        => __( 'Post Category', 'seovault-ai' ),
				'description' => __( 'First category of the current post.', 'seovault-ai' ),
				'variable'    => 'category',
				'example'     => $this->get_category() ?: __( 'Example Category', 'seovault-ai' ),
			),
			array( $this, 'get_category' )
		);

		$this->register_replacement(
			'categories',
			array(
				'name'        => __( 'Post Categories', 'seovault-ai' ),
				'description' => __( 'Comma-separated list of categories.', 'seovault-ai' ),
				'variable'    => 'categories',
				'example'     => $this->get_categories() ?: __( 'Category 1, Category 2', 'seovault-ai' ),
			),
			array( $this, 'get_categories' )
		);

		// Tag variables.
		$this->register_replacement(
			'tag',
			array(
				'name'        => __( 'Post Tag', 'seovault-ai' ),
				'description' => __( 'First tag of the current post.', 'seovault-ai' ),
				'variable'    => 'tag',
				'example'     => $this->get_tag() ?: __( 'Example Tag', 'seovault-ai' ),
				'nocache'     => true,
			),
			array( $this, 'get_tag' )
		);

		$this->register_replacement(
			'tags',
			array(
				'name'        => __( 'Post Tags', 'seovault-ai' ),
				'description' => __( 'Comma-separated list of tags.', 'seovault-ai' ),
				'variable'    => 'tags',
				'example'     => $this->get_tags() ?: __( 'Tag 1, Tag 2', 'seovault-ai' ),
				'nocache'     => true,
			),
			array( $this, 'get_tags' )
		);
	}

	// =========================================================================
	// Term Variables
	// =========================================================================

	/**
	 * Register term-related variables.
	 *
	 * @return void
	 */
	protected function setup_term_variables() {
		$this->register_replacement(
			'term',
			array(
				'name'        => __( 'Current Term', 'seovault-ai' ),
				'description' => __( 'Current term name.', 'seovault-ai' ),
				'variable'    => 'term',
				'example'     => $this->get_term() ?: __( 'Example Term', 'seovault-ai' ),
				'nocache'     => true,
			),
			array( $this, 'get_term' )
		);

		$this->register_replacement(
			'term_description',
			array(
				'name'        => __( 'Term Description', 'seovault-ai' ),
				'description' => __( 'Current term description.', 'seovault-ai' ),
				'variable'    => 'term_description',
				'example'     => $this->get_term_description() ?: __( 'Example term description', 'seovault-ai' ),
			),
			array( $this, 'get_term_description' )
		);

		$this->register_replacement(
			'customterm',
			array(
				'name'        => __( 'Custom Term (advanced)', 'seovault-ai' ),
				'description' => __( 'Custom term value by taxonomy.', 'seovault-ai' ),
				'variable'    => 'customterm(taxonomy-name)',
				'example'     => __( 'Custom term value', 'seovault-ai' ),
			),
			array( $this, 'get_custom_term' )
		);

		$this->register_replacement(
			'customterm_desc',
			array(
				'name'        => __( 'Custom Term Description (advanced)', 'seovault-ai' ),
				'description' => __( 'Custom term description by taxonomy.', 'seovault-ai' ),
				'variable'    => 'customterm_desc(taxonomy-name)',
				'example'     => __( 'Custom term description', 'seovault-ai' ),
			),
			array( $this, 'get_custom_term_desc' )
		);
	}

	// =========================================================================
	// Author Variables
	// =========================================================================

	/**
	 * Register author-related variables.
	 *
	 * @return void
	 */
	protected function setup_author_variables() {
		$this->register_replacement(
			'userid',
			array(
				'name'        => __( 'Author ID', 'seovault-ai' ),
				'description' => __( 'Author user ID of the current post or author archive.', 'seovault-ai' ),
				'variable'    => 'userid',
				'example'     => $this->get_userid(),
			),
			array( $this, 'get_userid' )
		);

		$this->register_replacement(
			'name',
			array(
				'name'        => __( 'Post Author', 'seovault-ai' ),
				'description' => __( 'Display name of the current post author.', 'seovault-ai' ),
				'variable'    => 'name',
				'example'     => $this->get_name() ?: __( 'Author Name', 'seovault-ai' ),
			),
			array( $this, 'get_name' )
		);

		$this->register_replacement(
			'post_author',
			array(
				'name'        => __( 'Post Author', 'seovault-ai' ),
				'description' => __( 'Display name of the current post author.', 'seovault-ai' ),
				'variable'    => 'post_author',
				'example'     => $this->get_name() ?: __( 'Author Name', 'seovault-ai' ),
			),
			array( $this, 'get_name' )
		);

		$this->register_replacement(
			'user_description',
			array(
				'name'        => __( 'Author Description', 'seovault-ai' ),
				'description' => __( 'Biographical info of the current post author.', 'seovault-ai' ),
				'variable'    => 'user_description',
				'example'     => $this->get_user_description() ?: __( 'Author bio', 'seovault-ai' ),
			),
			array( $this, 'get_user_description' )
		);
	}

	// =========================================================================
	// Advanced Variables
	// =========================================================================

	/**
	 * Register advanced variables.
	 *
	 * @return void
	 */
	protected function setup_advanced_variables() {
		$this->register_replacement(
			'id',
			array(
				'name'        => __( 'Post ID', 'seovault-ai' ),
				'description' => __( 'ID of the current post/page.', 'seovault-ai' ),
				'variable'    => 'id',
				'example'     => ! empty( $this->args->ID ) ? $this->args->ID : __( 'Post ID', 'seovault-ai' ),
			),
			array( $this, 'get_id' )
		);

		$this->register_replacement(
			'focuskw',
			array(
				'name'        => __( 'Focus Keyword', 'seovault-ai' ),
				'description' => __( 'Focus keyword of the current post.', 'seovault-ai' ),
				'variable'    => 'focuskw',
				'example'     => $this->get_focus_keyword() ?: __( 'focus keyword', 'seovault-ai' ),
			),
			array( $this, 'get_focus_keyword' )
		);

		$this->register_replacement(
			'keywords',
			array(
				'name'        => __( 'Focus Keywords', 'seovault-ai' ),
				'description' => __( 'All focus keywords of the current post.', 'seovault-ai' ),
				'variable'    => 'keywords',
				'example'     => $this->get_focus_keywords() ?: __( 'keyword 1, keyword 2', 'seovault-ai' ),
			),
			array( $this, 'get_focus_keywords' )
		);

		$this->register_replacement(
			'customfield',
			array(
				'name'        => __( 'Custom Field (advanced)', 'seovault-ai' ),
				'description' => __( 'Custom field value by name.', 'seovault-ai' ),
				'variable'    => 'customfield(field-name)',
				'example'     => __( 'Custom field value', 'seovault-ai' ),
				'nocache'     => true,
			),
			array( $this, 'get_customfield' )
		);

		// Paging variables.
		$this->register_replacement(
			'page',
			array(
				'name'        => __( 'Page', 'seovault-ai' ),
				'description' => __( 'Page number with context (e.g. page 2 of 4). Only on page 2+.', 'seovault-ai' ),
				'variable'    => 'page',
				'example'     => ' ' . $this->get_sep() . ' ' . sprintf(
					/* translators: %1$d = current page number, %2$d = total pages */
					__( 'Page %1$d of %2$d', 'seovault-ai' ),
					2,
					4
				),
			),
			array( $this, 'get_page' )
		);

		$this->register_replacement(
			'pagenumber',
			array(
				'name'        => __( 'Page Number', 'seovault-ai' ),
				'description' => __( 'Current page number.', 'seovault-ai' ),
				'variable'    => 'pagenumber',
				'example'     => '4',
			),
			array( $this, 'get_pagenumber' )
		);

		$this->register_replacement(
			'pagetotal',
			array(
				'name'        => __( 'Max Pages', 'seovault-ai' ),
				'description' => __( 'Total number of pages.', 'seovault-ai' ),
				'variable'    => 'pagetotal',
				'example'     => '4',
			),
			array( $this, 'get_pagetotal' )
		);

		// Post type variables.
		$this->register_replacement(
			'pt_single',
			array(
				'name'        => __( 'Post Type (singular)', 'seovault-ai' ),
				'description' => __( 'Singular name of the current post type.', 'seovault-ai' ),
				'variable'    => 'pt_single',
				'example'     => $this->get_post_type_single() ?: __( 'Post', 'seovault-ai' ),
			),
			array( $this, 'get_post_type_single' )
		);

		$this->register_replacement(
			'pt_plural',
			array(
				'name'        => __( 'Post Type (plural)', 'seovault-ai' ),
				'description' => __( 'Plural name of the current post type.', 'seovault-ai' ),
				'variable'    => 'pt_plural',
				'example'     => $this->get_post_type_plural() ?: __( 'Posts', 'seovault-ai' ),
			),
			array( $this, 'get_post_type_plural' )
		);

		// Image SEO variable.
		$this->register_replacement(
			'filename',
			array(
				'name'        => __( 'Image Filename', 'seovault-ai' ),
				'description' => __( 'The filename of the current image (without extension). Used in Image SEO alt format.', 'seovault-ai' ),
				'variable'    => 'filename',
				'example'     => __( 'my-image', 'seovault-ai' ),
				'nocache'     => true,
			),
			array( $this, 'get_filename' )
		);
	}

	// =========================================================================
	// Variable Getters — Basic
	// =========================================================================

	/**
	 * Get the separator.
	 *
	 * @return string
	 */
	public function get_sep() {
		$sep = Options::get_title_separator();
		return apply_filters( 'seovaultseo/vars/sep', $sep );
	}

	/**
	 * Get the site name.
	 *
	 * @return string
	 */
	public function get_sitename() {
		return wp_strip_all_tags( get_bloginfo( 'name' ), true );
	}

	/**
	 * Get the site description.
	 *
	 * @return string
	 */
	public function get_sitedesc() {
		return wp_strip_all_tags( get_bloginfo( 'description' ) );
	}

	/**
	 * Get the search query.
	 *
	 * @return string
	 */
	public function get_search_query() {
		return get_search_query();
	}

	/**
	 * Get current date.
	 *
	 * @param string $format Date format.
	 * @return string
	 */
	public function get_current_date( $format = '' ) {
		$format = $format ? $format : get_option( 'date_format' );
		return date_i18n( $format );
	}

	/**
	 * Get current day.
	 *
	 * @return string
	 */
	public function get_current_day() {
		return date_i18n( 'j' );
	}

	/**
	 * Get current month.
	 *
	 * @return string
	 */
	public function get_current_month() {
		return date_i18n( 'F' );
	}

	/**
	 * Get current year.
	 *
	 * @return string
	 */
	public function get_current_year() {
		return date_i18n( 'Y' );
	}

	/**
	 * Get current time.
	 *
	 * @param string $format Time format.
	 * @return string
	 */
	public function get_current_time( $format = '' ) {
		$format = $format ? $format : get_option( 'time_format' );
		return date_i18n( $format );
	}

	// =========================================================================
	// Variable Getters — Post
	// =========================================================================

	/**
	 * Get the post title.
	 *
	 * @return string|null
	 */
	public function get_title() {
		if ( is_post_type_archive() ) {
			$post_type = get_post_type();
			if ( $post_type ) {
				$pto = get_post_type_object( $post_type );
				if ( $pto ) {
					return $pto->labels->name;
				}
			}
		}

		return ! empty( $this->args->post_title ) ? stripslashes( $this->args->post_title ) : null;
	}

	/**
	 * Get the SEO title (from custom meta or template).
	 *
	 * @return string
	 */
	public function get_seo_title() {
		if ( ! empty( $this->args->ID ) ) {
			$title = PostMeta::get_seo_title( $this->args->ID );
			if ( '' !== $title ) {
				return $title;
			}
		}

		return $this->get_title() ?: '';
	}

	/**
	 * Get the SEO description (from custom meta or excerpt).
	 *
	 * @return string
	 */
	public function get_seo_description() {
		if ( ! empty( $this->args->ID ) ) {
			$desc = PostMeta::get_seo_description( $this->args->ID );
			if ( '' !== $desc ) {
				return $desc;
			}
		}

		return $this->get_excerpt() ?: '';
	}

	/**
	 * Get the parent page title.
	 *
	 * @return string|null
	 */
	public function get_parent_title() {
		if ( ! empty( $this->args->post_parent ) ) {
			return get_the_title( $this->args->post_parent );
		}

		return null;
	}

	/**
	 * Get the post excerpt (auto-generated if missing).
	 *
	 * @return string|null
	 */
	public function get_excerpt() {
		if ( empty( $this->args ) ) {
			return null;
		}

		if ( ! empty( $this->args->post_excerpt ) ) {
			return wp_strip_all_tags( $this->args->post_excerpt );
		}

		return $this->generate_excerpt_from_content();
	}

	/**
	 * Get the post excerpt without auto-generation.
	 *
	 * @return string|null
	 */
	public function get_excerpt_only() {
		if ( ! empty( $this->args->post_excerpt ) && ! empty( $this->args->ID ) ) {
			if ( ! post_password_required( $this->args->ID ) ) {
				return wp_strip_all_tags( $this->args->post_excerpt );
			}
		}

		return null;
	}

	/**
	 * Auto-generate an excerpt from post content.
	 *
	 * @return string
	 */
	protected function generate_excerpt_from_content() {
		if ( empty( $this->args->post_content ) ) {
			return '';
		}

		$content = $this->args->post_content;
		$content = preg_replace( '/<!--[\s\S]*?-->/iu', '', $content );
		$content = wp_strip_all_tags( strip_shortcodes( $content ) );
		$content = trim( preg_replace( '/\s+/', ' ', $content ) );

		return mb_substr( $content, 0, 160 );
	}

	/**
	 * Get the post URL.
	 *
	 * @return string|null
	 */
	public function get_url() {
		if ( ! empty( $this->args->ID ) ) {
			return get_permalink( $this->args->ID );
		}

		return null;
	}

	/**
	 * Get the post thumbnail URL.
	 *
	 * @return string
	 */
	public function get_post_thumbnail() {
		if ( empty( $this->args->ID ) || ! has_post_thumbnail( $this->args->ID ) ) {
			return '';
		}

		$image = wp_get_attachment_image_src( get_post_thumbnail_id( $this->args->ID ), 'full' );
		return ! empty( $image ) ? $image[0] : '';
	}

	/**
	 * Get the post date.
	 *
	 * @param string $format Date format.
	 * @return string|null
	 */
	public function get_date( $format = '' ) {
		if ( is_array( $format ) ) {
			$format = '';
		}

		if ( ! empty( $this->args->post_date ) ) {
			$format = $format ? $format : get_option( 'date_format' );
			return mysql2date( $format, $this->args->post_date, true );
		}

		if ( is_date() ) {
			return get_the_date( $format );
		}

		return null;
	}

	/**
	 * Get the post modified date.
	 *
	 * @param string $format Date format.
	 * @return string|null
	 */
	public function get_modified( $format = '' ) {
		if ( ! empty( $this->args->post_modified ) && ! empty( $this->args->post_date ) ) {
			$modified = strtotime( $this->args->post_date ) > strtotime( $this->args->post_modified ) ? $this->args->post_date : $this->args->post_modified;
			$format   = $format ? $format : get_option( 'date_format' );
			return mysql2date( $format, $modified, true );
		}

		return null;
	}

	/**
	 * Get the first post category.
	 *
	 * @return string|null
	 */
	public function get_category() {
		if ( ! empty( $this->args->ID ) ) {
			return $this->get_post_terms( $this->args->ID, 'category', true );
		}

		if ( is_category() ) {
			$term = get_queried_object();
			return $term ? $term->name : null;
		}

		return null;
	}

	/**
	 * Get all post categories.
	 *
	 * @return string|null
	 */
	public function get_categories() {
		if ( ! empty( $this->args->ID ) ) {
			return $this->get_post_terms( $this->args->ID, 'category', false );
		}

		return null;
	}

	/**
	 * Get the first post tag.
	 *
	 * @return string|null
	 */
	public function get_tag() {
		if ( ! empty( $this->args->ID ) ) {
			return $this->get_post_terms( $this->args->ID, 'post_tag', true );
		}

		if ( is_tag() ) {
			$term = get_queried_object();
			return $term ? $term->name : null;
		}

		return null;
	}

	/**
	 * Get all post tags.
	 *
	 * @return string|null
	 */
	public function get_tags() {
		if ( ! empty( $this->args->ID ) ) {
			return $this->get_post_terms( $this->args->ID, 'post_tag', false );
		}

		return null;
	}

	// =========================================================================
	// Variable Getters — Term
	// =========================================================================

	/**
	 * Get the current term name.
	 *
	 * @return string|null
	 */
	public function get_term() {
		if ( is_category() || is_tag() || is_tax() ) {
			$term = get_queried_object();
			return $term && isset( $term->name ) ? $term->name : null;
		}

		return ! empty( $this->args->taxonomy ) && ! empty( $this->args->name ) ? $this->args->name : null;
	}

	/**
	 * Get the current term description.
	 *
	 * @return string|null
	 */
	public function get_term_description() {
		if ( is_category() || is_tag() || is_tax() ) {
			$term = get_queried_object();
			return $term && isset( $term->description ) && '' !== $term->description ? wp_strip_all_tags( $term->description ) : null;
		}

		if ( ! empty( $this->args->term_id ) && ! empty( $this->args->taxonomy ) ) {
			$desc = get_term_field( 'description', $this->args->term_id, $this->args->taxonomy );
			return '' !== $desc && ! is_wp_error( $desc ) ? wp_strip_all_tags( $desc ) : null;
		}

		return null;
	}

	/**
	 * Get a custom taxonomy term name.
	 *
	 * @param string $taxonomy Taxonomy name.
	 * @return string|null
	 */
	public function get_custom_term( $taxonomy ) {
		if ( empty( $taxonomy ) || empty( $this->args->ID ) ) {
			return null;
		}

		return $this->get_post_terms( $this->args->ID, $taxonomy, true );
	}

	/**
	 * Get a custom taxonomy term description.
	 *
	 * @param string $taxonomy Taxonomy name.
	 * @return string|null
	 */
	public function get_custom_term_desc( $taxonomy ) {
		if ( empty( $taxonomy ) || empty( $this->args->ID ) ) {
			return null;
		}

		return $this->get_post_terms( $this->args->ID, $taxonomy, true, array(), 'description' );
	}

	// =========================================================================
	// Variable Getters — Author
	// =========================================================================

	/**
	 * Get the author user ID.
	 *
	 * @return string|null
	 */
	public function get_userid() {
		if ( ! empty( $this->args->post_author ) ) {
			return (string) $this->args->post_author;
		}

		if ( ! isset( $GLOBALS['wp_query'] ) || ! $GLOBALS['wp_query'] ) {
			return null;
		}

		$user_id = get_query_var( 'author' );
		return $user_id ? (string) $user_id : null;
	}

	/**
	 * Get the author display name.
	 *
	 * @return string|null
	 */
	public function get_name() {
		$user_id = $this->get_userid();
		if ( ! $user_id ) {
			return null;
		}

		$name = get_the_author_meta( 'display_name', $user_id );
		return '' !== $name ? $name : null;
	}

	/**
	 * Get the author description.
	 *
	 * @return string|null
	 */
	public function get_user_description() {
		$user_id = $this->get_userid();
		if ( ! $user_id ) {
			return null;
		}

		$desc = get_the_author_meta( 'description', $user_id );
		return '' !== $desc ? $desc : null;
	}

	// =========================================================================
	// Variable Getters — Advanced
	// =========================================================================

	/**
	 * Get the post ID.
	 *
	 * @return string|null
	 */
	public function get_id() {
		return ! empty( $this->args->ID ) ? (string) $this->args->ID : null;
	}

	/**
	 * Get the focus keyword (first one).
	 *
	 * @return string|null
	 */
	public function get_focus_keyword() {
		$keywords = $this->get_focus_keywords();
		if ( empty( $keywords ) ) {
			return null;
		}

		$parts = explode( ',', $keywords );
		return trim( $parts[0] ) !== '' ? trim( $parts[0] ) : null;
	}

	/**
	 * Get all focus keywords.
	 *
	 * @return string
	 */
	public function get_focus_keywords() {
		if ( ! empty( $this->args->ID ) ) {
			return PostMeta::get_focus_keyword( $this->args->ID );
		}

		if ( ! empty( $this->args->term_id ) ) {
			return get_term_meta( $this->args->term_id, '_seovault_focus_keyword', true );
		}

		return '';
	}

	/**
	 * Get a custom field value.
	 *
	 * @param string $name Custom field name.
	 * @return string|null
	 */
	public function get_customfield( $name ) {
		if ( empty( $name ) ) {
			return null;
		}

		if ( is_author() && ! empty( $this->args->ID ) ) {
			return get_user_meta( $this->args->ID, $name, true );
		}

		if ( ( is_category() || is_tag() || is_tax() ) && ! empty( $this->args->term_id ) ) {
			return get_term_meta( $this->args->term_id, $name, true );
		}

		if ( ! empty( $this->args->ID ) ) {
			$value = null;

			if ( function_exists( 'get_field' ) ) {
				$value = get_field( $name, $this->args->ID, true );
			}

			if ( null === $value ) {
				$value = get_post_meta( $this->args->ID, $name, true );
			}

			return Formatter::value_to_text( $value );
		}

		return null;
	}

	/**
	 * Get page number with context.
	 *
	 * @return string|null
	 */
	public function get_page() {
		$sep  = $this->get_sep();
		$max  = $this->determine_max_pages();
		$page = $this->determine_page_number();

		if ( $max > 1 && $page > 1 ) {
			return $sep . ' ' . sprintf(
				/* translators: %1$d = current page number, %2$d = total pages */
				__( 'Page %1$d of %2$d', 'seovault-ai' ),
				$page,
				$max
			);
		}

		return null;
	}

	/**
	 * Get just the page number.
	 *
	 * @return string|null
	 */
	public function get_pagenumber() {
		$page = $this->determine_page_number();
		return $page > 0 ? (string) $page : null;
	}

	/**
	 * Get the total number of pages.
	 *
	 * @return string|null
	 */
	public function get_pagetotal() {
		$max = $this->determine_max_pages();
		return $max > 0 ? (string) $max : null;
	}

	/**
	 * Get the post type singular label.
	 *
	 * @return string|null
	 */
	public function get_post_type_single() {
		$post_type = $this->get_current_post_type();
		if ( ! $post_type ) {
			return null;
		}

		$pto = get_post_type_object( $post_type );
		return $pto && isset( $pto->labels->singular_name ) ? $pto->labels->singular_name : $post_type;
	}

	/**
	 * Get the post type plural label.
	 *
	 * @return string|null
	 */
	public function get_post_type_plural() {
		$post_type = $this->get_current_post_type();
		if ( ! $post_type ) {
			return null;
		}

		$pto = get_post_type_object( $post_type );
		return $pto && isset( $pto->labels->name ) ? $pto->labels->name : $post_type;
	}

	/**
	 * Get the image filename (without extension) from the current args context.
	 * Used by Image SEO for alt attribute generation.
	 *
	 * @return string
	 */
	public function get_filename() {
		if ( isset( $this->args->filename ) ) {
			return (string) $this->args->filename;
		}
		return '';
	}

	// =========================================================================
	// Helper Methods
	// =========================================================================

	/**
	 * Get terms for a post as a comma-separated string or single value.
	 *
	 * @param int    $post_id       Post ID.
	 * @param string $taxonomy      Taxonomy name.
	 * @param bool   $return_single Return only the first term.
	 * @param array  $args          Extra args (limit, separator, exclude).
	 * @param string $field         Term field to return.
	 * @return string
	 */
	protected function get_post_terms( $post_id, $taxonomy, $return_single = false, $args = array(), $field = 'name' ) {
		// Check for queried term on taxonomy archives.
		if ( ( is_category() || is_tag() || is_tax() ) && $return_single ) {
			$term = get_queried_object();
			if ( $term && isset( $term->{$field} ) ) {
				return $term->{$field};
			}
		}

		if ( empty( $post_id ) || empty( $taxonomy ) ) {
			return '';
		}

		$args = wp_parse_args(
			$args,
			array(
				'limit'     => 99,
				'separator' => ', ',
				'exclude'   => array(), // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_exclude -- Term list filter arg, not WP_Query.
			)
		);

		$terms = get_the_terms( $post_id, $taxonomy );
		if ( is_wp_error( $terms ) || empty( $terms ) ) {
			return '';
		}

		if ( ! empty( $args['exclude'] ) && ! is_array( $args['exclude'] ) ) {
			$args['exclude'] = array_map( 'intval', explode( ',', $args['exclude'] ) ); // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_exclude -- Term list filter arg, not WP_Query.
		}

		if ( ! empty( $args['exclude'] ) ) {
			$terms = array_filter(
				$terms,
				function ( $term ) use ( $args ) {
					return ! in_array( $term->term_id, $args['exclude'], true );
				}
			);
		}

		$terms = array_slice( $terms, 0, (int) $args['limit'] );
		if ( empty( $terms ) ) {
			return '';
		}

		if ( $return_single ) {
			return $terms[0]->{$field};
		}

		return implode( $args['separator'], wp_list_pluck( $terms, $field ) );
	}

	/**
	 * Determine the current page number.
	 *
	 * @return int
	 */
	protected function determine_page_number() {
		$page = is_singular() ? get_query_var( 'page' ) : get_query_var( 'paged' );
		return ( 0 === $page || '' === $page ) ? 1 : (int) $page;
	}

	/**
	 * Determine the max number of pages.
	 *
	 * @return int
	 */
	protected function determine_max_pages() {
		global $wp_query, $post;

		if ( is_singular() && isset( $post->post_content ) ) {
			return substr_count( $post->post_content, '<!--nextpage-->' ) + 1;
		}

		return empty( $wp_query->max_num_pages ) ? 1 : (int) $wp_query->max_num_pages;
	}

	/**
	 * Get the current post type from query.
	 *
	 * @return string|false
	 */
	protected function get_current_post_type() {
		if ( is_singular() ) {
			return get_post_type();
		}

		if ( is_post_type_archive() ) {
			return get_post_type();
		}

		$post_type = get_query_var( 'post_type' );
		if ( is_array( $post_type ) ) {
			return reset( $post_type ) ?: false;
		}

		if ( $post_type ) {
			return $post_type;
		}

		if ( ! empty( $this->args->post_type ) ) {
			return $this->args->post_type;
		}

		return false;
	}

	// =========================================================================
	// Static Convenience
	// =========================================================================

	// =========================================================================
	// Static helpers
	// =========================================================================

	/**
	 * Clear the static replacement lookup cache.
	 *
	 * Used between tests and when site identity options change mid-request.
	 *
	 * @return void
	 */
	public static function reset_replacement_cache() {
		self::$replacements_cache = array();
	}

	/**
	 * Static convenience wrapper for replace().
	 *
	 * @param string $string String to process.
	 * @param array  $args   Context args.
	 * @return string
	 */
	public static function do_replace( $string, $args = array() ) {
		if ( empty( $string ) ) {
			return '';
		}

		return self::get_instance()->replace( $string, $args );
	}
}
