<?php
/**
 * Serialization-safe search-replace across every text column in every table.
 *
 * Used after restore to migrate a backup taken on site A onto site B
 * (different domain, different filesystem path). Walks recursively through
 * serialized PHP, JSON, and nested arrays/objects so string lengths inside
 * `s:N:"..."` declarations are recalculated correctly.
 *
 * Algorithm is the same one used by WP-CLI's `wp search-replace` and
 * interconnectit's Search-Replace-DB — the industry reference for this.
 *
 * @package Backreload\Core
 */

namespace Backreload\Core;

if ( ! defined( 'ABSPATH' ) ) { exit; }

class MigrationEngine {

	private ?Logger $logger;

	public function __construct( ?Logger $logger = null ) {
		$this->logger = $logger;
	}

	/**
	 * Run replacements against every text column in every table.
	 *
	 * @param array<string,string> $replacements  search => replace map. Longest-key-first matters,
	 *                                            so the caller should pre-sort if needed.
	 * @return array{tables:int,rows_changed:int,cells_changed:int}
	 */
	public function rewrite( array $replacements ): array {
		global $wpdb;

		// Remove empty / no-op pairs.
		$replacements = array_filter( $replacements, fn( $r, $s ) => $s !== '' && $s !== $r, ARRAY_FILTER_USE_BOTH );
		if ( empty( $replacements ) ) {
			return [ 'tables' => 0, 'rows_changed' => 0, 'cells_changed' => 0 ];
		}

		// Run longest search strings first so e.g. "https://x.com/path" beats "https://x.com".
		uksort( $replacements, fn( $a, $b ) => strlen( $b ) - strlen( $a ) );

		$stats = [ 'tables' => 0, 'rows_changed' => 0, 'cells_changed' => 0 ];

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin reads/writes its own custom tables; results are transient backup state and must not be cached.
		$tables = $wpdb->get_col( 'SHOW TABLES' );
		foreach ( $tables as $table ) {
			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Table name from $wpdb->prefix (never user input), cannot be bound via prepare(); reads the site's own schema, result is transient and uncacheable.
			$cols      = $wpdb->get_results( "SHOW COLUMNS FROM `{$table}`" );
			$pk        = null;
			$text_cols = [];
			foreach ( $cols as $c ) {
				if ( 'PRI' === ( $c->Key ?? '' ) && null === $pk ) {
					$pk = $c->Field;
				}
				if ( preg_match( '/^(varchar|char|tinytext|text|mediumtext|longtext)/i', $c->Type ) ) {
					$text_cols[] = $c->Field;
				}
			}
			if ( ! $pk || empty( $text_cols ) ) {
				continue;
			}

			$stats['tables']++;
			$select_cols = '`' . implode( '`, `', array_merge( [ $pk ], $text_cols ) ) . '`';

			// Keyset pagination. The old `LIMIT N OFFSET M` walk forced MySQL
			// to read and discard M rows on every iteration — O(N²) overall.
			// On a 10M-row `wp_postmeta` (large-store / WooCommerce site) the
			// final pages were each scanning ~10M rows just to skip them,
			// pushing the migration into hours of pure CPU.
			//
			// Keyset replaces OFFSET with `WHERE pk > last_seen_pk`, which
			// hits the PK index directly — each page is O(log n) regardless
			// of how far in we are. It's also race-safe: rows updated by
			// $wpdb->update() inside the loop don't shift the cursor, because
			// we move forward by PK value, not by row position.
			//
			// Caveat: this assumes the PK is a single column with strictly
			// monotonic, non-NULL values. All WP core tables satisfy that
			// (every `_meta`/`_options`/`posts` has `id` BIGINT AUTO_INCREMENT
			// or `option_name` PRI). Composite-PK tables fall back to the
			// first PRI column — same behavior as the old code.
			$last_pk   = null;
			$chunk     = 200;
			$total_chg = 0;

			while ( true ) {
				if ( null === $last_pk ) {
					// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Table/column/PK identifiers derive from $wpdb->prefix and the table's own introspected schema (never user input); cannot be bound via prepare(); reads the site's own tables, transient and uncacheable.
					$rows = $wpdb->get_results(
						$wpdb->prepare(
							"SELECT {$select_cols} FROM `{$table}` ORDER BY `{$pk}` ASC LIMIT %d",
							$chunk
						),
						ARRAY_A
					);
					// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
				} else {
					// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Table/column/PK identifiers derive from $wpdb->prefix and the table's own introspected schema (never user input); cannot be bound via prepare(); reads the site's own tables, transient and uncacheable.
					$rows = $wpdb->get_results(
						$wpdb->prepare(
							"SELECT {$select_cols} FROM `{$table}` WHERE `{$pk}` > %s ORDER BY `{$pk}` ASC LIMIT %d",
							$last_pk,
							$chunk
						),
						ARRAY_A
					);
					// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
				}
				if ( empty( $rows ) ) break;

				foreach ( $rows as $row ) {
					$changes = [];
					foreach ( $text_cols as $col ) {
						$orig = $row[ $col ];
						if ( null === $orig || '' === $orig ) continue;
						$modified = $this->replace_recursive( $replacements, $orig );
						if ( $modified !== $orig ) {
							$changes[ $col ] = $modified;
							$stats['cells_changed']++;
						}
					}
					if ( $changes ) {
						// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin reads/writes its own custom tables; results are transient backup state and must not be cached.
						$wpdb->update( $table, $changes, [ $pk => $row[ $pk ] ] );
						$stats['rows_changed']++;
						$total_chg++;
					}
					$last_pk = $row[ $pk ];
				}
				// Defensive: a chunk smaller than $chunk means we read the
				// final page. Bail explicitly rather than spending one more
				// keyset query on the empty tail (the next iteration's
				// empty-result check would catch it anyway, but this saves
				// a round trip).
				if ( count( $rows ) < $chunk ) break;
			}

			if ( $total_chg > 0 ) {
				$this->logger && $this->logger->info( "Rewrote {$total_chg} rows in {$table}" );
			}
		}

		$this->logger && $this->logger->success(
			"URL/path rewrite complete: {$stats['cells_changed']} cells in {$stats['rows_changed']} rows across {$stats['tables']} tables"
		);
		return $stats;
	}

	/**
	 * Recursive replacement that walks PHP-serialized and JSON structures
	 * correctly. Returns the modified value.
	 *
	 * @param array<string,string> $replacements
	 * @param mixed $data
	 * @return mixed
	 */
	private function replace_recursive( array $replacements, $data ) {
		if ( is_string( $data ) ) {
			// 1. PHP-serialized string? Unserialize, recurse, re-serialize.
			//
			// Security: raw unserialize() on attacker-controlled data is the
			// classic PHP RCE vector — a crafted O:N:"ClassName":... blob can
			// trigger magic-method gadget chains (__wakeup / __destruct) in
			// any class loaded into the WP process. We close that with two
			// belts:
			//
			//   a. Detect object tokens (O:N:"...") in the raw serialized
			//      string BEFORE calling unserialize. If present, return the
			//      original value untouched — we can't safely walk it
			//      (unserialize is RCE) and we can't safely str_replace it
			//      (would break the s:N:"..." length prefixes), so the only
			//      safe move is to skip the rewrite for this row.
			//   b. Pass allowed_classes=false to unserialize as a defence in
			//      depth, in case some pathological input slips past the
			//      regex (e.g. an O:-token escaped in a way is_serialized
			//      accepted but the regex missed).
			//
			// Trade-off: rows holding serialized OBJECTS keep their original
			// URLs after migration. Plugin/theme data rarely embeds URLs
			// inside object properties at depth ≥1 — the common cases
			// (option strings, post meta arrays, theme mods) are all caught
			// by the safe paths above. A site that hits the rare miss can
			// re-run a manual SRDB pass to mop up.
			if ( is_serialized( $data ) ) {
				if ( preg_match( '/(?:^|;)O:\d+:"/', $data ) ) {
					return $data;
				}
				$unser = @unserialize( $data, [ 'allowed_classes' => false ] );
				if ( false !== $unser || 'b:0;' === $data ) {
					$modified = $this->replace_recursive( $replacements, $unser );
					return serialize( $modified );
				}
				// `is_serialized()` said yes but `unserialize()` returned false
				// (and the input isn't the legitimate `b:0;`). Truncated /
				// charset-mangled / legacy-encoded blobs end up here. DO NOT
				// fall through to step 3's `str_replace` — that would rewrite
				// bytes inside the payload while leaving the `s:N:"…"` length
				// prefixes pointing at the OLD length, producing a payload that
				// future unserialize calls reject. Result on a real site: a
				// random option/post_meta value silently becomes unreadable
				// after migration. Leaving the original value alone is the
				// only safe move when we can't roundtrip-deserialize it.
				return $data;
			}
			// 2. JSON? Decode → recurse → re-encode. Detect cheap with first char.
			$first = $data[0] ?? '';
			if ( '{' === $first || '[' === $first ) {
				$decoded = json_decode( $data, true );
				if ( null !== $decoded && JSON_ERROR_NONE === json_last_error() ) {
					$modified = $this->replace_recursive( $replacements, $decoded );
					$encoded  = wp_json_encode( $modified );
					if ( false !== $encoded ) return $encoded;
				}
			}
			// 3. Plain string replacement.
			foreach ( $replacements as $search => $replace ) {
				$data = str_replace( $search, $replace, $data );
			}
			return $data;
		}

		if ( is_array( $data ) ) {
			foreach ( $data as $k => $v ) {
				$data[ $k ] = $this->replace_recursive( $replacements, $v );
			}
			return $data;
		}

		if ( is_object( $data ) ) {
			// stdClass / generic objects: walk public properties.
			foreach ( get_object_vars( $data ) as $k => $v ) {
				$data->$k = $this->replace_recursive( $replacements, $v );
			}
			return $data;
		}

		return $data;
	}
}
