<?php
/**
 * Groups: Search & Replace Keywords class.
 *
 * @package Page_Generator_Pro
 * @author WP Zinc
 */

/**
 * Provides a "Replace Keywords" action on the Content Groups list table.
 *
 * Given a Content Group ID, a "find" keyword name and a "replace" keyword name, this
 * class will rewrite every occurrence of {find}, {find(column)}, {find:modifier},
 * {find(column):modifier}, etc. so the base keyword name becomes the replacement,
 * preserving column and modifier tails verbatim.
 *
 * Rewrites happen across the Group's post_title, post_content, and every post_meta
 * key the Group holds (the PGP settings blob, page builder layouts, SEO/schema
 * metadata, custom fields, etc.).
 *
 * @package Page_Generator_Pro
 * @author  WP Zinc
 * @version 5.5.6
 */
class Page_Generator_Pro_Groups_Search_Replace {

	/**
	 * Holds the base object.
	 *
	 * @since   5.5.6
	 *
	 * @var     object
	 */
	public $base;

	/**
	 * Constructor.
	 *
	 * @since   5.5.6
	 *
	 * @param   object $base    Base Plugin Class.
	 */
	public function __construct( $base ) {

		$this->base = $base;

		// Add row action to the Content Groups list table.
		add_filter( 'page_generator_pro_groups_table_admin_row_actions', array( $this, 'add_row_action' ), 10, 2 );

		// Enqueue assets on the Content Groups list screen. The modal markup itself is
		// the shared wpzinc-modal output by the dashboard widget; we inject our content
		// into it from JS at open time.
		add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );

		// AJAX endpoints.
		add_action( 'wp_ajax_page_generator_pro_groups_search_replace_get_keywords_in_use', array( $this, 'ajax_get_keywords_in_use' ) );
		add_action( 'wp_ajax_page_generator_pro_groups_search_replace_replace', array( $this, 'ajax_replace' ) );

	}

	/**
	 * Adds the "Replace Keywords" row action to the Content Groups list table.
	 *
	 * @since   5.5.6
	 *
	 * @param   array   $actions    Existing row actions.
	 * @param   WP_Post $post       The Content Group post.
	 * @return  array               Row actions with our entry appended.
	 */
	public function add_row_action( $actions, $post ) {

		if ( ! current_user_can( 'edit_post', $post->ID ) ) {
			return $actions;
		}

		$actions['replace_keywords'] = sprintf(
			'<a href="#" class="page-generator-pro-replace-keywords" data-group-id="%d" data-group-title="%s" data-edit-url="%s">%s</a>',
			(int) $post->ID,
			esc_attr( get_the_title( $post ) ),
			esc_url( get_edit_post_link( $post->ID, 'raw' ) ),
			esc_html__( 'Replace Keywords', 'page-generator-pro' )
		);

		return $actions;

	}

	/**
	 * Enqueues the JS and CSS that drive the modal, but only on the Content Groups
	 * list table screen.
	 *
	 * @since   5.5.6
	 */
	public function enqueue_assets() {

		// Bail if not on the Content Groups list table screen.
		if ( ! $this->is_groups_list_screen() ) {
			return;
		}

		// Load minified JS if enabled.
		$minified = $this->base->dashboard->should_load_minified_js();

		// wpzinc-admin-modal is registered by the dashboard widget and enqueued by
		// Page_Generator_Pro_Admin on the Content Groups list screen; we list it as an
		// explicit dependency so our JS can rely on wpzinc_modal_*() being defined.
		wp_enqueue_script(
			$this->base->plugin->name . '-groups-search-replace',
			$this->base->plugin->url . 'assets/js/' . ( $minified ? 'min/' : '' ) . 'groups-search-replace' . ( $minified ? '-min' : '' ) . '.js',
			array( 'jquery', 'wpzinc-admin-modal' ),
			$this->base->plugin->version,
			true
		);

		// Localize the JS.
		wp_localize_script(
			$this->base->plugin->name . '-groups-search-replace',
			'page_generator_pro_groups_search_replace',
			array(
				'ajax_url'           => admin_url( 'admin-ajax.php' ),
				'nonce'              => wp_create_nonce( 'page-generator-pro-groups-search-replace' ),
				'available_keywords' => $this->get_all_keyword_names(),
				'i18n'               => array(
					// These strings are rendered into the DOM via jQuery .text(), which
					// performs its own escaping. Using __() (rather than esc_html__())
					// avoids double-escaping apostrophes and ampersands.
					'modal_title'          => __( 'Replace Keywords', 'page-generator-pro' ),
					'modal_subtitle'       => __( 'Rewrites every {keyword} token in this Content Group, keeping any column or modifier suffix unchanged. Leave a row\'s Replace dropdown blank to leave that keyword untouched. There is no undo (other than post revisions on title/content).', 'page-generator-pro' ),
					'find_heading'         => __( 'Find', 'page-generator-pro' ),
					'occurrences_heading'  => __( 'Occurrences', 'page-generator-pro' ),
					'replace_with_heading' => __( 'Replace with', 'page-generator-pro' ),
					'leave_unchanged'      => __( '— Leave unchanged —', 'page-generator-pro' ),
					'no_keywords_in_group' => __( 'No keywords were detected in this Content Group.', 'page-generator-pro' ),
					'confirm_replace'      => __( 'This will rewrite every occurrence in this Content Group. There is no undo (other than post revisions for the title/content). Proceed?', 'page-generator-pro' ),
					/* translators: Number of occurrences updated */
					'replace_done'         => __( 'Replacement complete. Occurrences updated: %d', 'page-generator-pro' ),
					'error_generic'        => __( 'An unexpected error occurred. Please try again.', 'page-generator-pro' ),
					'replace'              => __( 'Replace', 'page-generator-pro' ),
					'cancel'               => __( 'Cancel', 'page-generator-pro' ),
					'view_content_group'   => __( 'View Content Group', 'page-generator-pro' ),
				),
			)
		);

		// CSS for our table + action bar injected into the shared .wpzinc-modal lives in
		// assets/scss/admin.scss, which is already enqueued plugin-wide as the
		// "<plugin_name>-admin" stylesheet. No per-screen CSS enqueue needed here.
	}

	/**
	 * Returns true when we're on the Content Groups list table screen
	 * (edit.php?post_type=page-generator-pro).
	 *
	 * @since   5.5.6
	 *
	 * @return  bool
	 */
	private function is_groups_list_screen() {

		$screen = $this->base->get_class( 'screen' )->get_current_screen();

		return ( $screen['screen'] === 'content_groups' && $screen['section'] === 'wp_list_table' );

	}

	/**
	 * AJAX: returns the list of keyword names actually in use in the given Content Group,
	 * along with a per-field occurrence breakdown.
	 *
	 * @since   5.5.6
	 */
	public function ajax_get_keywords_in_use() {

		check_ajax_referer( 'page-generator-pro-groups-search-replace', 'nonce' );

		if ( ! isset( $_REQUEST['group_id'] ) ) {
			wp_send_json_error( __( 'Missing Group ID.', 'page-generator-pro' ) );
		}

		$group_id = absint( $_REQUEST['group_id'] );

		if ( ! current_user_can( 'edit_post', $group_id ) ) {
			wp_send_json_error( __( 'You do not have permission to edit this Content Group.', 'page-generator-pro' ) );
		}

		$result = $this->get_keywords_in_use( $group_id );

		if ( is_wp_error( $result ) ) {
			wp_send_json_error( $result->get_error_message() );
		}

		wp_send_json_success( $result );

	}

	/**
	 * AJAX: performs the replacement of one or more keyword names across the given
	 * Content Group. The request body must include a `pairs` array of objects, each
	 * with `find` and `replace` string keys.
	 *
	 * @since   5.5.6
	 */
	public function ajax_replace() {

		check_ajax_referer( 'page-generator-pro-groups-search-replace', 'nonce' );

		if ( ! isset( $_REQUEST['group_id'] ) || ! isset( $_REQUEST['pairs'] ) ) {
			wp_send_json_error( __( 'Missing required parameters.', 'page-generator-pro' ) );
		}

		$group_id  = absint( $_REQUEST['group_id'] );
		$raw_pairs = wp_unslash( $_REQUEST['pairs'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput

		if ( ! current_user_can( 'edit_post', $group_id ) ) {
			wp_send_json_error( __( 'You do not have permission to edit this Content Group.', 'page-generator-pro' ) );
		}

		// Normalise pairs into an array of [from => to] strings, sanitising each value.
		$pairs = array();

		if ( is_array( $raw_pairs ) ) {
			foreach ( $raw_pairs as $pair ) {
				if ( ! is_array( $pair ) || ! isset( $pair['find'], $pair['replace'] ) ) {
					continue;
				}

				$from = sanitize_text_field( (string) $pair['find'] );
				$to   = sanitize_text_field( (string) $pair['replace'] );

				if ( $from === '' || $to === '' || $from === $to ) {
					continue;
				}

				$pairs[ $from ] = $to;
			}
		}

		if ( empty( $pairs ) ) {
			wp_send_json_error( __( 'No valid keyword replacements were supplied.', 'page-generator-pro' ) );
		}

		$result = $this->replace_many( $group_id, $pairs );

		if ( is_wp_error( $result ) ) {
			wp_send_json_error( $result->get_error_message() );
		}

		wp_send_json_success( $result );

	}

	/**
	 * Returns the list of base keyword names in use in the given Content Group, validated
	 * against the keywords table, along with the variants used (e.g. column or modifier
	 * suffixes) and per-field occurrence counts.
	 *
	 * @since   5.5.6
	 *
	 * @param   int $group_id   Content Group ID.
	 * @return  WP_Error|array
	 */
	public function get_keywords_in_use( $group_id ) {

		$settings = $this->base->get_class( 'groups' )->get_settings( $group_id, false, false );

		if ( is_wp_error( $settings ) ) {
			return $settings;
		}

		// Use the existing keyword detector to identify validated keywords and their full
		// variants. This filters against the keywords table, so freeform {brace} tokens
		// that aren't real keywords are ignored.
		$detected = $this->base->get_class( 'generate' )->find_keywords_in_settings( $settings );

		if ( empty( $detected['required_keywords'] ) ) {
			return array(
				'keywords' => array(),
			);
		}

		// Single pass to build per-keyword occurrence breakdown plus the response. The
		// breakdown is built per top-level settings field for the UI, by walking each
		// field separately.
		$result = array();

		foreach ( $detected['required_keywords'] as $keyword_name ) {
			$regex            = $this->build_token_regex( $keyword_name );
			$per_field_counts = array();

			foreach ( $settings as $field => $value ) {
				$count = $this->count_in_value( $value, $regex );

				if ( $count > 0 ) {
					$per_field_counts[ $field ] = $count;
				}
			}

			$result[] = array(
				'name'        => $keyword_name,
				'variants'    => isset( $detected['required_keywords_full'][ $keyword_name ] )
					? $detected['required_keywords_full'][ $keyword_name ]
					: array( $keyword_name ),
				'occurrences' => $per_field_counts,
				'total'       => array_sum( $per_field_counts ),
			);
		}

		return array(
			'keywords' => $result,
		);

	}

	/**
	 * Replaces every occurrence of one or more "find" keyword names with their corresponding
	 * "replace" keyword names across the given Content Group. Column and modifier tails on
	 * each token are preserved verbatim.
	 *
	 * Multiple pairs are applied via a two-pass placeholder strategy: in pass 1, each find
	 * keyword's tokens are rewritten to a unique placeholder token; in pass 2, the placeholders
	 * are rewritten to their final replacement values. This ensures that mapping such as
	 * `location -> service` and `service -> product` in the same submit produces
	 * `{location}` -> `{service}` and the original `{service}` -> `{product}`, rather than
	 * incorrectly chaining `{location}` all the way through to `{product}`.
	 *
	 * @since   5.5.6
	 *
	 * @param   int   $group_id   Content Group ID.
	 * @param   array $pairs      Map of `find_keyword => replace_keyword`.
	 * @return  WP_Error|array    Per-field count of occurrences replaced, plus total.
	 */
	public function replace_many( $group_id, $pairs ) {

		// Validate the group ID.
		if ( get_post_type( $group_id ) !== $this->base->get_class( 'post_type' )->post_type_name ) {
			return new WP_Error(
				'page_generator_pro_groups_search_replace_invalid_group',
				__( 'The given ID is not a Content Group.', 'page-generator-pro' )
			);
		}

		// Validate that every keyword name on both sides exists in the keywords table.
		$known_keywords = $this->get_all_keyword_names();

		foreach ( $pairs as $find => $replace ) {
			if ( ! in_array( $find, $known_keywords, true ) ) {
				return new WP_Error(
					'page_generator_pro_groups_search_replace_invalid_find',
					/* translators: keyword name */
					sprintf( __( 'The keyword "%s" is not defined in the Keywords list.', 'page-generator-pro' ), $find )
				);
			}

			if ( ! in_array( $replace, $known_keywords, true ) ) {
				return new WP_Error(
					'page_generator_pro_groups_search_replace_invalid_replace',
					/* translators: keyword name */
					sprintf( __( 'The keyword "%s" is not defined in the Keywords list.', 'page-generator-pro' ), $replace )
				);
			}
		}

		// Build the two-pass strategy:
		//
		// Pass 1: for each $find, rewrite {find...} to {<placeholder for pair index>...},
		// where the placeholder is a sentinel string that cannot appear in legitimate user
		// content. We use null bytes (\x00) plus a fixed marker so the placeholder is
		// distinguishable from anything the user could have typed.
		//
		// Pass 2: a single regex finds every placeholder and rewrites it to its final
		// replacement keyword name.
		$placeholder_prefix = "\0PGP_REPL_";
		$placeholder_suffix = "\0";

		$pair_index = 0;
		$pass_one   = array();
		$pass_two   = array();

		foreach ( $pairs as $find => $replace ) {
			$placeholder = $placeholder_prefix . $pair_index . $placeholder_suffix;

			$pass_one[] = array(
				'regex'       => $this->build_token_regex( $find ),
				'placeholder' => $placeholder,
			);

			$pass_two[ $placeholder ] = $replace;

			++$pair_index;
		}

		$counts = array(
			'title'   => 0,
			'content' => 0,
			'meta'    => 0,
		);

		// Counter the callbacks increment; reset before each preg_replace_callback call.
		$counter = 0;

		$pass_one_callback = function ( $matches ) use ( &$counter, &$current_placeholder ) {
			++$counter;
			// Sandwich the tail (column + modifiers) between the placeholder and a closing
			// brace, so a single pass 2 regex can find the placeholder and rewrite the
			// surrounding `{placeholder<tail>}` into `{<replacement><tail>}`.
			return '{' . $current_placeholder . $matches[2] . '}';
		};

		// Compile the pass 2 regex once. It matches `{<placeholder><tail>}` and captures
		// the tail so we can preserve it verbatim.
		$pass_two_regex = '/\{(' . preg_quote( $placeholder_prefix, '/' ) . '\d+' . preg_quote( $placeholder_suffix, '/' ) . ')((?:\([^)]*\))?(?::[\p{L}0-9_\-,\[\]]+)*)\}/u';

		$pass_two_callback = function ( $matches ) use ( $pass_two ) {
			$placeholder = $matches[1];
			$tail        = $matches[2];

			if ( ! isset( $pass_two[ $placeholder ] ) ) {
				// Defensive: if we somehow hit a placeholder we don't have a mapping for,
				// leave it alone rather than corrupting it.
				return $matches[0];
			}

			return '{' . $pass_two[ $placeholder ] . $tail . '}';
		};

		/**
		 * Runs both passes against a given string, returning the rewritten string. The
		 * per-pair pass 1 increments $counter via the shared callback.
		 */
		$rewrite_string = function ( $value ) use ( $pass_one, $pass_two_regex, $pass_one_callback, $pass_two_callback, &$current_placeholder ) {
			foreach ( $pass_one as $pair ) {
				$current_placeholder = $pair['placeholder'];
				$value               = preg_replace_callback( $pair['regex'], $pass_one_callback, $value );
			}
			return preg_replace_callback( $pass_two_regex, $pass_two_callback, $value );
		};

		// Wrap the rewrite for walk_and_replace's mixed-type signature.
		$walker = function ( $value ) use ( &$walker, $rewrite_string ) {
			if ( is_string( $value ) ) {
				return $rewrite_string( $value );
			}

			if ( is_array( $value ) ) {
				foreach ( $value as $key => $child ) {
					$value[ $key ] = $walker( $child );
				}
				return $value;
			}

			if ( is_object( $value ) ) {
				foreach ( get_object_vars( $value ) as $key => $child ) {
					$value->{ $key } = $walker( $child );
				}
				return $value;
			}

			return $value;
		};

		$current_placeholder = ''; // Initialised in pass 1; declared here so the closures share it.

		// 1. post_title and post_content.
		$post = get_post( $group_id );
		if ( $post ) {
			$counter         = 0;
			$new_title       = $walker( $post->post_title );
			$counts['title'] = $counter;

			$counter           = 0;
			$new_content       = $walker( $post->post_content );
			$counts['content'] = $counter;

			if ( $new_title !== $post->post_title || $new_content !== $post->post_content ) {
				wp_update_post(
					array(
						'ID'           => $group_id,
						'post_title'   => $new_title,
						'post_content' => $new_content,
					)
				);
			}
		}

		// 2. Every post_meta key the Group holds — the PGP settings blob, page builder
		// layouts, SEO/schema metadata, custom fields, etc. We deliberately do not
		// exclude any keys: the goal is to replace every {keyword} occurrence, wherever
		// it lives.
		$all_meta = get_post_meta( $group_id );
		if ( is_array( $all_meta ) ) {
			foreach ( $all_meta as $meta_key => $meta_values ) {
				foreach ( $meta_values as $meta_value ) {
					$unserialised = maybe_unserialize( $meta_value );

					$counter   = 0;
					$rewritten = $walker( $unserialised );

					if ( $counter > 0 ) {
						update_post_meta( $group_id, $meta_key, $rewritten, $meta_value );
						$counts['meta'] += $counter;
					}
				}
			}
		}

		$counts['total'] = $counts['title'] + $counts['content'] + $counts['meta'];

		return $counts;

	}

	/**
	 * Returns all keyword names defined in the keywords table.
	 *
	 * @since   5.5.6
	 *
	 * @return  array
	 */
	private function get_all_keyword_names() {

		$names = $this->base->get_class( 'keywords' )->get_keywords_names();

		return is_array( $names ) ? $names : array();

	}

	/**
	 * Recursively counts the number of regex matches in the given value (which may be a
	 * scalar, array, or object).
	 *
	 * @since   5.5.6
	 *
	 * @param   mixed  $value   Value to inspect.
	 * @param   string $regex   Compiled regex.
	 * @return  int
	 */
	private function count_in_value( $value, $regex ) {

		if ( is_string( $value ) ) {
			return (int) preg_match_all( $regex, $value );
		}

		if ( is_array( $value ) ) {
			$total = 0;
			foreach ( $value as $child ) {
				$total += $this->count_in_value( $child, $regex );
			}
			return $total;
		}

		if ( is_object( $value ) ) {
			$total = 0;
			foreach ( get_object_vars( $value ) as $child ) {
				$total += $this->count_in_value( $child, $regex );
			}
			return $total;
		}

		return 0;

	}

	/**
	 * Builds the regex used to match every {keyword} token for the given base keyword
	 * name, capturing the column-and-modifier tail in capture group 2 so the caller can
	 * preserve it verbatim when rebuilding the token.
	 *
	 * Mirrors the token grammar accepted by Page_Generator_Pro_Generate::find_keywords_in_string():
	 *
	 *   {name}
	 *   {name(column)}
	 *   {name:modifier}
	 *   {name:modifier:modifier2}
	 *   {name:modifier[arg1,arg2]}
	 *   {name(column):modifier[arg1,arg2]}
	 *
	 * @since   5.5.6
	 *
	 * @param   string $keyword_name   Base keyword name to match.
	 * @return  string                 Compiled regex (delimiters and /u flag included).
	 */
	private function build_token_regex( $keyword_name ) {

		return '/\{(' . preg_quote( $keyword_name, '/' ) . ')((?:\([^)]*\))?(?::[\p{L}0-9_\-,\[\]]+)*)\}/u';

	}

}
