<?php
/**
 * Search-Replace Engine Trait — serialized-PHP-aware text replacement
 * across WordPress tables.
 *
 * Extracted from RunWpCli to keep the orchestrating class small. This
 * trait is private to the RunWpCli ability and depends only on $wpdb,
 * WordPress core (`is_serialized`) and the standard Response helper —
 * not on any RunWpCli state.
 *
 * @package zip-ai
 */

namespace ZipAI\MCP\Classes\Abilities\Core;

defined( 'ABSPATH' ) || exit;

use ZipAI\MCP\Classes\Core\Response;

/**
 * Trait for `wp search-replace` execution.
 *
 * Walks every text column of every selected table, deserializing
 * payloads when present so the length prefix that a naïve UPDATE …
 * REPLACE() would corrupt stays consistent. `guid` and `user_pass`
 * are always skipped.
 */
trait Search_Replace_Engine_Trait {

	/**
	 * Handle "search-replace <old> <new> [--all-tables] [--skip-columns=…] [--dry-run]".
	 *
	 * Serialized-PHP-aware: when a cell holds a serialized structure, the
	 * structure is deserialized, replaced recursively, then reserialized — so
	 * we don't corrupt the length prefix that a naïve UPDATE ... REPLACE()
	 * would break.
	 *
	 * The `guid` and `user_pass` columns are ALWAYS skipped. `guid` must not
	 * change post-publish (it identifies the post in RSS readers); `user_pass`
	 * is a bcrypt hash and a string replace would brick logins.
	 *
	 * Without `--all-tables`, only core WP tables (`$wpdb->tables()`) are
	 * walked. With it, every table with the wpdb prefix is walked.
	 *
	 * @param string[] $positional Remaining positional args ([0]=old, [1]=new).
	 * @param array    $flags      Parsed flags.
	 * @return array
	 */
	private function handle_search_replace( array $positional, array $flags ): array {
		global $wpdb;

		$old = $positional[0] ?? null;
		$new = $positional[1] ?? null;

		if ( null === $old || '' === $old ) {
			return Response::error(
				'Usage: search-replace "<old>" "<new>" [--all-tables] [--skip-columns=col1,col2] [--dry-run]'
			);
		}
		if ( null === $new ) {
			return Response::error( 'Replacement string missing. Use "" to delete occurrences.' );
		}

		// Hard cap — defensive. A 5 KB needle has no realistic site use.
		if ( strlen( $old ) > 5000 || strlen( (string) $new ) > 5000 ) {
			return Response::error( 'Search or replace string is larger than 5 KB. Run smaller substitutions.' );
		}

		// Refuse no-op replacements early — they would walk the entire DB
		// for nothing.
		if ( $old === $new ) {
			return Response::error( 'Old and new strings are identical — no replacement to perform.' );
		}

		$dry_run    = ! empty( $flags['dry-run'] );
		$all_tables = ! empty( $flags['all-tables'] ) || ! empty( $flags['all-tables-with-prefix'] );

		// Always-skip columns. `guid` is the post permalink identity used by
		// feed readers; `user_pass` is a bcrypt hash. Both must never be
		// mutated by a text replacement.
		$always_skip = array( 'guid', 'user_pass' );
		$user_skip   = array();
		if ( ! empty( $flags['skip-columns'] ) ) {
			$user_skip = array_filter(
				array_map( 'trim', explode( ',', (string) $flags['skip-columns'] ) )
			);
		}
		$skip_columns = array_unique( array_merge( $always_skip, $user_skip ) );

		// Resolve the set of tables to walk.
		if ( $all_tables ) {
			$like = $wpdb->esc_like( $wpdb->prefix ) . '%';
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
			$tables = $wpdb->get_col( $wpdb->prepare( 'SHOW TABLES LIKE %s', $like ) );
		} else {
			$tables = array_values( $wpdb->tables() );
		}

		$report        = array();
		$total_changes = 0;

		foreach ( (array) $tables as $table ) {
			$table = (string) $table;
			if ( '' === $table ) {
				continue;
			}
			$primary_key = $this->table_primary_key( $table );
			if ( null === $primary_key ) {
				continue; // Skip tables without a single-column PK.
			}
			$text_columns = $this->table_text_columns( $table );
			if ( empty( $text_columns ) ) {
				continue;
			}
			foreach ( $text_columns as $column ) {
				if ( in_array( $column, $skip_columns, true ) ) {
					continue;
				}
				$changed = $this->search_replace_column( $table, $column, $primary_key, (string) $old, (string) $new, $dry_run );
				if ( $changed > 0 ) {
					if ( ! isset( $report[ $table ] ) ) {
						$report[ $table ] = array();
					}
					$report[ $table ][ $column ] = $changed;
					$total_changes              += $changed;
				}
			}
		}

		return Response::success(
			array(
				'dry_run'         => $dry_run,
				'total_changes'   => $total_changes,
				'tables_changed'  => $report,
				'skipped_columns' => array_values( $skip_columns ),
			)
		);
	}

	/**
	 * Return the single-column primary key of a table, or null when the table
	 * has a composite or no primary key.
	 *
	 * @param string $table Fully-qualified table name (with prefix).
	 * @return string|null
	 */
	private function table_primary_key( string $table ): ?string {
		global $wpdb;
		// `SHOW KEYS` cannot use placeholders for identifiers, but the table
		// name has been resolved from $wpdb->tables() / SHOW TABLES — never
		// from user input — and is backtick-quoted defensively.
		$safe_table = str_replace( '`', '', $table );
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$rows = $wpdb->get_results( "SHOW KEYS FROM `{$safe_table}` WHERE Key_name = 'PRIMARY'", ARRAY_A );
		if ( ! is_array( $rows ) || 1 !== count( $rows ) ) {
			return null;
		}
		$col = (string) ( $rows[0]['Column_name'] ?? '' );
		return '' === $col ? null : $col;
	}

	/**
	 * Return the names of text-typed columns in a table (char/varchar/text
	 * family). Only these columns can hold the kind of values search-replace
	 * needs to touch.
	 *
	 * @param string $table Table name (with prefix).
	 * @return string[]
	 */
	private function table_text_columns( string $table ): array {
		global $wpdb;
		$safe_table = str_replace( '`', '', $table );
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$rows = $wpdb->get_results( "SHOW COLUMNS FROM `{$safe_table}`", ARRAY_A );
		if ( ! is_array( $rows ) ) {
			return array();
		}
		$out = array();
		foreach ( $rows as $row ) {
			$type = strtolower( (string) ( $row['Type'] ?? '' ) );
			if ( preg_match( '/^(char|varchar|tinytext|text|mediumtext|longtext|enum|set)/', $type ) ) {
				$out[] = (string) $row['Field'];
			}
		}
		return $out;
	}

	/**
	 * Replace occurrences of $old with $new in one column of one table.
	 *
	 * The scan is keyed by primary-key list rather than offset paging so
	 * concurrent UPDATEs that move rows out of the LIKE result set don't
	 * shift the cursor.
	 *
	 * @param string $table       Table name.
	 * @param string $column      Column name.
	 * @param string $primary_key Primary-key column.
	 * @param string $old         Search string.
	 * @param string $new         Replacement string.
	 * @param bool   $dry_run     When true, count matches without writing.
	 * @return int Number of rows whose value changed.
	 */
	private function search_replace_column( string $table, string $column, string $primary_key, string $old, string $new, bool $dry_run ): int {
		global $wpdb;

		$safe_table  = str_replace( '`', '', $table );
		$safe_column = str_replace( '`', '', $column );
		$safe_pk     = str_replace( '`', '', $primary_key );
		$like        = '%' . $wpdb->esc_like( $old ) . '%';

		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$pk_sql = $wpdb->prepare(
			"SELECT `{$safe_pk}` FROM `{$safe_table}` WHERE `{$safe_column}` LIKE %s",
			$like
		);
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$pks = $wpdb->get_col( $pk_sql );
		if ( empty( $pks ) ) {
			return 0;
		}

		$changes    = 0;
		$batch_size = 200;

		foreach ( array_chunk( $pks, $batch_size ) as $chunk ) {
			$placeholders = implode( ',', array_fill( 0, count( $chunk ), '%s' ) );
			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			$batch_sql = $wpdb->prepare(
				"SELECT `{$safe_pk}` AS pk, `{$safe_column}` AS val FROM `{$safe_table}` WHERE `{$safe_pk}` IN ({$placeholders})",
				$chunk
			);
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
			$rows = $wpdb->get_results( $batch_sql );
			if ( ! is_array( $rows ) ) {
				continue;
			}
			foreach ( $rows as $row ) {
				$original = (string) $row->val;
				$replaced = $this->replace_recursively( $original, $old, $new );
				if ( ! is_string( $replaced ) || $replaced === $original ) {
					continue;
				}
				if ( $dry_run ) {
					++$changes;
					continue;
				}
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
				$updated = $wpdb->update(
					$table,
					array( $column => $replaced ),
					array( $primary_key => $row->pk )
				);
				// wpdb::update() returns the number of rows changed, 0 when the row
				// no longer matched (e.g. a concurrent writer), or false on a DB
				// error. Count only rows that actually changed so the reported
				// total never over-reports persisted writes.
				if ( $updated > 0 ) {
					++$changes;
				}
			}
		}

		return $changes;
	}

	/**
	 * Replace $old with $new inside $data, descending through serialized
	 * payloads (arrays / objects / nested serialized strings).
	 *
	 * Returns a string in all branches so the caller can compare against the
	 * original cell value verbatim. Serialized inputs are returned as
	 * serialized strings; plain strings as plain strings.
	 *
	 * `unserialize` is called with `allowed_classes => false` so a poisoned
	 * payload cannot instantiate plugin/theme classes during the walk.
	 *
	 * @param mixed  $data  Value to recurse into.
	 * @param string $old   Search string.
	 * @param string $new   Replacement string.
	 * @param int    $depth Recursion guard.
	 * @return mixed
	 */
	private function replace_recursively( $data, string $old, string $new, int $depth = 0 ) {
		if ( $depth > 50 ) {
			return $data;
		}
		if ( is_string( $data ) ) {
			if ( is_serialized( $data ) ) {
				$unserialized = @unserialize( $data, array( 'allowed_classes' => false ) );
				if ( false !== $unserialized || 'b:0;' === $data ) {
					$replaced = $this->replace_recursively( $unserialized, $old, $new, $depth + 1 );
					return serialize( $replaced );
				}
			}
			return str_replace( $old, $new, $data );
		}
		if ( is_array( $data ) ) {
			foreach ( $data as $k => $v ) {
				$data[ $k ] = $this->replace_recursively( $v, $old, $new, $depth + 1 );
			}
			return $data;
		}
		if ( is_object( $data ) ) {
			$clone = clone $data;
			foreach ( get_object_vars( $clone ) as $k => $v ) {
				$clone->$k = $this->replace_recursively( $v, $old, $new, $depth + 1 );
			}
			return $clone;
		}
		return $data;
	}
}
