HEX
Server: LiteSpeed
System: Linux s787.bom1.mysecurecloudhost.com 4.18.0-477.13.1.lve.el8.x86_64 #1 SMP Thu Jun 1 16:40:47 EDT 2023 x86_64
User: mobilech (5348)
PHP: 8.1.34
Disabled: NONE
Upload Files
File: /home/mobilech/public_html/wp-content/plugins/backup/src/JetBackup/Cron/Task/Restore.php
<?php

namespace JetBackup\Cron\Task;

use __PHP_Incomplete_Class;
use Exception;
use JetBackup\BackupJob\BackupJob;
use JetBackup\Data\Engine;
use JetBackup\Data\Mysqldump;
use JetBackup\Encryption\Crypt;
use JetBackup\Entities\Util;
use JetBackup\Exception\DBException;
use JetBackup\Exception\ExecutionTimeException;
use JetBackup\Exception\JetBackupLinuxException;
use JetBackup\Exception\RestoreException;
use JetBackup\Exception\SnapshotMetaException;
use JetBackup\Exception\TaskException;
use JetBackup\Factory;
use JetBackup\Filesystem\File;
use JetBackup\JetBackup;
use JetBackup\JetBackupLinux\JetBackupLinux;
use JetBackup\Queue\Queue;
use JetBackup\Queue\QueueItem;
use JetBackup\Queue\QueueItemRestore;
use JetBackup\Snapshot\Snapshot;
use SleekDB\Exceptions\InvalidArgumentException;
use SleekDB\Exceptions\IOException;

if (!defined( '__JETBACKUP__')) die('Direct access is not allowed');

class Restore extends Task {

	const LOG_FILENAME = 'restore';

	const PLUGINS_PATH = JetBackup::WP_ROOT_PATH . JetBackup::SEP . 'wp-content' . JetBackup::SEP . 'plugins';
	const MU_PLUGINS_PATH = JetBackup::WP_ROOT_PATH . JetBackup::SEP . 'wp-content' . JetBackup::SEP . 'mu-plugins';

	const PROTECTED_PLUGINS = ['backup']; // Add this protected attribute to store protected plugin names.
	const CACHE_PLUGINS = ['redis-cache'];

	private QueueItemRestore $_queue_item_restore;
	private Mysqldump $_mysql;
	private array $_disabled_plugins=[];

	/**
	 *
	 */
	public function __construct() {
		parent::__construct(self::LOG_FILENAME);
	}

	/**
	 * @return void
	 * @throws IOException
	 * @throws InvalidArgumentException
	 * @throws TaskException
	 * @throws ExecutionTimeException
	 */
	public function execute():void {
		parent::execute();

		$this->_queue_item_restore = $this->getQueueItem()->getItemData();

		if($this->getQueueItem()->getStatus() == Queue::STATUS_RESTORE_WAITING_FOR_RESTORE) {
			$this->getLogController()->logMessage("Starting restore");

			$this->getQueueItem()->getProgress()->setTotalItems( count(Queue::STATUS_RESTORE_NAMES) + 3);
			$this->getQueueItem()->save();
			$this->getQueueItem()->updateProgress('Starting restore');


		} else if($this->getQueueItem()->getStatus() > Queue::STATUS_RESTORE_WAITING_FOR_RESTORE) {
			$this->getLogController()->logMessage('Resumed Restore');
		}

		try {

			if(!$this->_queue_item_restore->getSnapshotId() && !$this->_queue_item_restore->getSnapshotPath())
				throw new RestoreException("No snapshot id or path provided");
			
			$this->getLogController()->logDebug('Item data: ' . print_r($this->_queue_item_restore, 1));

			$snapshot = $this->getSnapshot();

			if($snapshot->getEngine() == Engine::ENGINE_JB) {
				$this->func([$this, '_addToQueue']);
				$this->func([$this, '_updateQueue']);
			} else {
				$mysql_auth = $this->_fetchMySQLAuth();
				$this->_mysql = new Mysqldump($mysql_auth->db_name, $mysql_auth->db_user, $mysql_auth->db_password, $mysql_auth->db_host);
				$this->_mysql->setLogController($this->getLogController());

				$this->func([$this, '_preFetchAdminUser']);
				$this->func([$this, '_database']);
				$this->func([$this, '_files']);
				$this->func([$this, '_postRestoreDBPrefix']);
				$this->func([$this, '_rewriteViewDefinersAndPrefixes']);
				$this->func([$this, '_validateViewsCompile']);
				$this->func([$this, '_postRestoreDomainMigration']);
				$this->func([$this, '_postInsertAdminUser']);
				$this->func([$this, '_postRestoreHealthCheck']);
				$this->func([$this, '_postRestoreActions']); // must be called after WordPress is loaded
			}

			if($this->getQueueItem()->getStatus() < Queue::STATUS_DONE) $this->getQueueItem()->updateStatus(Queue::STATUS_DONE);
			$this->getLogController()->logMessage('Completed!');
		} catch(ExecutionTimeException $e) {
			throw $e;
		} catch( Exception $e) {
			$this->getQueueItem()->updateStatus(Queue::STATUS_FAILED);
			$this->getLogController()->logError($e->getMessage());
			$this->getLogController()->logMessage('Failed!');
		}

		$this->getQueueItem()->updateProgress($this->getQueueItem()->getStatus() == Queue::STATUS_DONE ? 'Restore Completed' : 'Error during restore', QueueItem::PROGRESS_LAST_STEP);
		$this->getLogController()->logMessage('Total time: ' . $this->getExecutionTimeElapsed());
	}

	public function _validateViewsCompile(): void {
		$views = $this->_mysql->query_exec(
			"SELECT TABLE_NAME FROM INFORMATION_SCHEMA.VIEWS WHERE TABLE_SCHEMA = DATABASE()"
		) ?? [];

		$workspace     = $this->getQueueItem()->getWorkspace();
		$database_path = $workspace . JetBackup::SEP . Snapshot::SKELETON_DATABASE_DIRNAME;
		$visited = [];

		foreach ($views as $v) {
			$name = $v->TABLE_NAME ?? current((array)$v);
			try {
				// Force dependency resolution
				$this->_mysql->query_exec("SELECT * FROM `{$name}` LIMIT 0");
			} catch (\Throwable $e) {
				$msg = $e->getMessage();
				$this->getLogController()->logError("View `{$name}` fails to resolve: " . $msg);

				// 1) Try to (re)import this view from snapshot dumps (pulls its deps too)
				try {
					$dumpPath = self::_dumpPathFor($database_path, $name);
					if ($dumpPath) {
						$this->getLogController()->logMessage("Attempting to (re)import missing/broken view `{$name}` from dump...");
						$this->_importWithDeps($database_path, $name, $visited);

						// Re-test after import
						$this->_mysql->query_exec("SELECT * FROM `{$name}` LIMIT 0");
						$this->getLogController()->logMessage("View `{$name}` now resolves after re-import.");
						continue; // good now
					} else {
						$this->getLogController()->logMessage("No dump found for view `{$name}`.");
					}
				} catch (\Throwable $e2) {
					$this->getLogController()->logError("Re-import attempt for `{$name}` failed: " . $e2->getMessage());
				}

				// 2) Last resort: drop the broken view so it won’t crash the site at runtime
				try {
					$this->getLogController()->logMessage("Dropping unresolved view `{$name}` to avoid runtime failures.");
					$this->_mysql->query_exec("DROP VIEW IF EXISTS `{$name}`");
				} catch (\Throwable $e3) {
					$this->getLogController()->logError("Failed dropping unresolved view `{$name}`: " . $e3->getMessage());
				}
			}
		}
	}


	public function _updateQueue() {
		if(!$this->_queue_item_restore->getQueueGroupId()) throw new RestoreException("Can't find queue group");

		$this->getLogController()->logMessage('Monitoring JetBackup Linux queue item status');

		while(true) {

			try {
				$response = JetBackupLinux::getQueueGroup($this->_queue_item_restore->getQueueGroupId());
			} catch(JetBackupLinuxException $e) {
				throw new RestoreException("Failed fetching JetBackup Linux queue item status. Error: " . $e->getMessage());
			}

			$remote_progress = $response['progress'];
			$progress = $this->getQueueItem()->getProgress();

			$current_status = JetBackupLinux::QUEUE_STATUS_RESTORE_MAPPING[$response['status']] ?? 0;
			$old_status = $this->getQueueItem()->getStatus();

			if($current_status) {
				$name = JetBackupLinux::QUEUE_STATUS_RESTORE_ACCOUNT_NAMES[$response['status']];
				if($current_status != $old_status) {
					$this->getQueueItem()->updateStatus($current_status);
					$this->getQueueItem()->updateProgress($name);
				}

				if($response['status'] == JetBackupLinux::QUEUE_STATUS_RESTORE_ACCOUNT_HOMEDIR) {
					$name .= " ({$remote_progress['completed_files']}/{$remote_progress['total_files']})";
				}

				$this->getLogController()->logMessage($name . '...');
			}

			if($response['status'] == JetBackupLinux::QUEUE_STATUS_RESTORE_ACCOUNT_HOMEDIR && isset($remote_progress['total_files']) && intval($remote_progress['total_files']) > 1) {
				$progress->setTotalSubItems((int) $remote_progress['total_files']);
				$progress->setCurrentSubItem((int) $remote_progress['completed_files']);
			} else {
				if(isset($remote_progress['subtotal'])) $progress->setTotalSubItems($remote_progress['subtotal']);
				if(isset($remote_progress['subcompleted'])) $progress->setCurrentSubItem($remote_progress['subcompleted']);
			}

			if($response['status'] >= JetBackupLinux::QUEUE_STATUS_COMPLETED) {
				$this->getQueueItem()->setStatus(JetBackupLinux::QUEUE_STATUS_MAPPING[$response['status']]);
				$this->getQueueItem()->save();
				return;
			} else {
				$progress->setMessage(JetBackupLinux::QUEUE_STATUS_RESTORE_ACCOUNT_NAMES[$response['status']] ?? 'Processing');
				$this->getQueueItem()->save();
			}

			sleep(5);
		}
	}

	/**
	 * Recreate all views so they:
	 *  - reference the **local** table prefix (not the backup’s)
	 *  - have NO explicit DEFINER
	 *  - use SQL SECURITY INVOKER
	 *  - are created with CREATE OR REPLACE (idempotent)
	 *  - are applied in dependency order (retry passes)
	 */
	public function _rewriteViewDefinersAndPrefixes(): void {
		$this->getLogController()->logMessage("Rewriting view definers and prefixes...");

		$mysql_auth    = $this->_fetchMySQLAuth();
		$local_prefix  = $mysql_auth->table_prefix;
		$backup_prefix = $this->_getDatabasePrefix();

		if (!$backup_prefix || $backup_prefix === $local_prefix) {
			$this->getLogController()->logMessage("No prefix change detected; skipping view rewrite.");
			return;
		}

		// Fetch all view names in current DB
		$rows = $this->_mysql->query_exec(
			"SELECT TABLE_NAME FROM INFORMATION_SCHEMA.VIEWS WHERE TABLE_SCHEMA = DATABASE()"
		) ?? [];

		$views = [];
		foreach ($rows as $r) {
			$views[] = $r->TABLE_NAME ?? current((array)$r);
		}

		// Helper: does an object (table or view) exist now?
		$exists = function(string $name): bool {
			try { return $this->_mysql->tableExists($name, true); } catch (\Exception $e) { return false; }
		};

		/**
		 * Build a queue: for each source view, prepare:
		 *  - the final (normalized, prefix-fixed) DDL
		 *  - the new target name (with local prefix)
		 *  - the conservative list of referenced objects (to order creation)
		 */
		$queue = []; // [oldName => ['new' => newName, 'ddl' => ddl, 'deps' => []]]
		foreach ($views as $oldName) {
			if (!$oldName) continue;

			$row = $this->_mysql->query_exec("SHOW CREATE VIEW `{$oldName}`");
			$ddl = (!empty($row) && isset($row[0])) ? ($row[0]->{'Create View'} ?? null) : null;
			if (!$ddl) {
				$this->getLogController()->logMessage("SHOW CREATE VIEW returned empty for `{$oldName}`, skipping.");
				continue;
			}

			// 1) Normalize header (remove DEFINER/ALGORITHM, force OR REPLACE + INVOKER)
			$ddl = $this->_normalizeCreateViewSQL($ddl);

			// 2) Replace backup prefix -> local prefix in identifiers (header + body)
			$ddl = preg_replace_callback(
				'/`([A-Za-z0-9_]+)`/u',
				function ($m) use ($backup_prefix, $local_prefix) {
					$tok = $m[1];
					return (strpos($tok, $backup_prefix) === 0)
						? '`' . $local_prefix . substr($tok, strlen($backup_prefix)) . '`'
						: $m[0];
				},
				$ddl
			);
			$ddl = preg_replace(
				'/\b' . preg_quote($backup_prefix, '/') . '([A-Za-z0-9_]+)\b/u',
				$local_prefix . '$1',
				$ddl
			);

			// 3) Decide the new view name (swap prefix if the name itself starts with backup_prefix)
			$newName = (strpos($oldName, $backup_prefix) === 0)
				? $local_prefix . substr($oldName, strlen($backup_prefix))
				: $oldName;

			// 4) Ensure the CREATE header uses the new object name
			$ddl = preg_replace(
				'/\bCREATE\s+(?:OR\s+REPLACE\s+)?(?:SQL\s+SECURITY\s+INVOKER\s+)?VIEW\s+`?([A-Za-z0-9_]+)`?/i',
				'CREATE OR REPLACE SQL SECURITY INVOKER VIEW `' . $newName . '`',
				$ddl,
				1
			);

			// 5) Collect referenced objects conservatively from the DDL body
			$deps = [];
			if (preg_match('/\bAS\b(.*)\z/is', $ddl, $mBody)) {
				$body = $mBody[1];
				// FROM/JOIN `db`.`obj` or `obj` or db.obj or obj
				if (preg_match_all('/\b(?:FROM|JOIN)\s+`?([A-Za-z0-9_]+)`?(?:\.`?([A-Za-z0-9_]+)`?)?/i', $body, $mm, PREG_SET_ORDER)) {
					foreach ($mm as $g) {
						$obj = isset($g[2]) && $g[2] ? $g[2] : $g[1];
						if (strpos($obj, $backup_prefix) === 0) {
							$obj = $local_prefix . substr($obj, strlen($backup_prefix));
						}
						$deps[] = $obj;
					}
				}
			}
			$deps = array_values(array_unique($deps));

			$queue[$oldName] = ['new' => $newName, 'ddl' => $ddl, 'deps' => $deps];
		}

		// Retry passes: create views whose deps already exist; loop until no progress
		$maxPasses = 5;
		for ($pass = 1; $pass <= $maxPasses && !empty($queue); $pass++) {
			$progress = 0;

			foreach (array_keys($queue) as $oldName) {
				$newName = $queue[$oldName]['new'];
				$ddl     = $queue[$oldName]['ddl'];
				$deps    = $queue[$oldName]['deps'];

				// if any dependency doesn't exist yet, skip for this pass
				$blocked = false;
				foreach ($deps as $d) {
					if (!$exists($d)) { $blocked = true; break; }
				}

				if ($blocked) {
					$this->getLogController()->logMessage("Waiting to recreate `{$oldName}`; missing deps: " . implode(', ', array_filter($deps, fn($d) => !$exists($d))));
					continue;
				}


				try {
					// be idempotent on target name
					$this->_mysql->query_exec("DROP VIEW IF EXISTS `{$newName}`");
					$this->_mysql->query_exec($ddl);

					// if we changed the name, drop the old name to prevent stale alias
					if ($newName !== $oldName) {
						$this->_mysql->query_exec("DROP VIEW IF EXISTS `{$oldName}`");
					}

					$this->getLogController()->logMessage("Recreated view `{$newName}` (from `{$oldName}`) with updated prefix and INVOKER.");
					unset($queue[$oldName]);
					$progress++;
				} catch (\Throwable $e) {
					$this->getLogController()->logError("Failed to recreate view `{$oldName}` → `{$newName}`: " . $e->getMessage());
					// leave it for next pass
				}
			}

			if ($progress === 0) break; // nothing more we can resolve by ordering
		}

		// Any unresolved views will be surfaced by _validateViewsCompile()
	}


	/**
	 * Header-level normalization for CREATE VIEW:
	 *  - remove DEFINER and ALGORITHM
	 *  - force CREATE OR REPLACE
	 *  - force SQL SECURITY INVOKER
	 */
	public function _normalizeCreateViewSQL(string $sql): string {
		if (!preg_match('/\bCREATE\b.*\bVIEW\b/is', $sql)) return $sql;

		// Split once at first AS (header vs body)
		$parts  = preg_split('/\bAS\b/i', $sql, 2);
		$header = $parts[0] ?? $sql;
		$body   = $parts[1] ?? '';

		// Remove versioned DEFINER blocks like /*!50013 DEFINER=`user`@`host` SQL SECURITY DEFINER */
		$header = preg_replace(
			'/\/\*!\d+\s+DEFINER\s*=\s*[^*]+SQL\s+SECURITY\s+(?:DEFINER|INVOKER)\s*\*\//i',
			' ',
			$header
		);
		// Remove plain DEFINER=...
		$header = preg_replace(
			'/\bDEFINER\s*=\s*(?:`[^`]+`@`[^`]+`|\'[^\']+\'@\'[^\']+\'|[^ \t\n\r\f\)]+)\s*/i',
			' ',
			$header
		);
		// Remove ALGORITHM=...
		$header = preg_replace('/\bALGORITHM\s*=\s*\w+\s*/i', ' ', $header);

		// Force CREATE OR REPLACE
		$header = preg_replace('/\bCREATE\s+(?!OR\s+REPLACE\b)/i', 'CREATE OR REPLACE ', $header, 1);

		// Ensure SQL SECURITY INVOKER
		if (preg_match('/\bSQL\s+SECURITY\s+(?:DEFINER|INVOKER)\b/i', $header)) {
			$header = preg_replace('/\bSQL\s+SECURITY\s+(?:DEFINER|INVOKER)\b/i', 'SQL SECURITY INVOKER', $header, 1);
		} else {
			$header = preg_replace(
				'/\b(CREATE\s+(?:OR\s+REPLACE\s+)?)(VIEW\b)/i',
				'$1SQL SECURITY INVOKER $2',
				$header,
				1
			);
		}

		// Tidy whitespace
		$header = preg_replace('/[ \t]+/', ' ', trim($header));

		if ($body === '') return $header;
		return $header . ' AS' . (preg_match('/^\s/', $body) ? '' : ' ') . $body;
	}


	/**
	 * @throws DBException
	 * @throws RestoreException
	 * @throws IOException
	 * @throws InvalidArgumentException
	 */
	public function _addToQueue() {
		if($this->_queue_item_restore->getQueueGroupId()) return;

		$this->getLogController()->logMessage('Adding restore to JetBackup Linux queue');

		$snapshot = $this->getSnapshot();

		$files = $items = [];
		$homedir_item_id = $database_item_id = null;

		foreach ($snapshot->getItems() as $item) {
			switch($item->getBackupContains()) {
				case BackupJob::BACKUP_ACCOUNT_CONTAINS_HOMEDIR:
					$homedir_item_id = $item->getUniqueId();
					break;
				case BackupJob::BACKUP_ACCOUNT_CONTAINS_DATABASE:
					$database_item_id = $item->getUniqueId();
					break;
			}
		}

		$options = $this->_queue_item_restore->getOptions();

		if ($homedir_item_id && ($options & QueueItemRestore::OPTION_RESTORE_FILES_ENTIRE)) {
			$files[$homedir_item_id][Factory::getWPHelper()->getWordPressRelativePublicDir()] = 'Directory';
			$items[] = $homedir_item_id;
		}

		if ($homedir_item_id && ($options & QueueItemRestore::OPTION_RESTORE_FILES_INCLUDE)) {

			$files = [];

			$public_dir = Factory::getWPHelper()->getWordPressRelativePublicDir();

			foreach ($this->_queue_item_restore->getFileManager() as $file) {
				$path = $public_dir . JetBackup::SEP . trim($file['path'], JetBackup::SEP);
				$this->getLogController()->logMessage("Path: $path");
				$files[$homedir_item_id][$path] = $file['type'];
			}

			$items[] = $homedir_item_id;
		}


		if ($database_item_id && ($options & QueueItemRestore::OPTION_RESTORE_DATABASE_ENTIRE)) {
			$items[] = $database_item_id;
		}

		try {
			$response = JetBackupLinux::addQueueItems($items, $files, ['merge' => 1]);
		} catch (Exception $e) {
			throw new RestoreException("Failed adding to JetBackup Linux queue. Error: " . $e->getMessage());
		}

		$this->_queue_item_restore->setQueueGroupId($response['_id']);
		$this->getQueueItem()->save();
	}

	/**
	 * @return Snapshot
	 */
	private function getSnapshot():Snapshot {

		return $this->func(function() {

			if($this->_queue_item_restore->getSnapshotId()) {
				$snapshot = new Snapshot($this->_queue_item_restore->getSnapshotId());
				if($snapshot->getId() && $snapshot->getEngine() == Engine::ENGINE_JB) return $snapshot;
			}

			$workspace = $this->getQueueItem()->getWorkspace();
			$meta_file = sprintf(Snapshot::META_FILEPATH, $workspace);

			if(!file_exists($meta_file)) throw new RestoreException("Backup meta file not exists ($meta_file)");

			$snapshot = new Snapshot();
			try {
				$snapshot->importMeta($meta_file, true);
			} catch(SnapshotMetaException $e) {
				throw new RestoreException("Failed read snapshot meta file. Error: " . $e->getMessage());
			}

			return $snapshot;

		}, [], '_loadSnapshot');
	}

	/**
	 * @return object|null
	 */
	private function _fetchMySQLAuth():object {

		return $this->func(function() {

			try {
				// Pass queue unique ID as decryption key for runtime credentials
				return Factory::getWPHelper()::parseWpConfig($this->getQueueItem()->getUniqueId());
			} catch (Exception $e) {
				throw new RestoreException($e->getMessage());
			}

		}, [], '_MySQLAuth');
	}

	/**
	 * @param string $dumpPath
	 *
	 * @return bool
	 */
	private static function _isViewDump(string $dumpPath): bool {
		$fh = @fopen($dumpPath, 'rb');
		if (!$fh) return false;
		$chunk = fread($fh, 262144); // read a small chunk
		fclose($fh);
		if ($chunk === false || $chunk === '') return false;

		// normalize & look for CREATE VIEW
		$sql = preg_replace('/\/\*![0-9]+\s*/', ' ', $chunk);
		$sql = str_replace('*/', ' ', $sql);
		$sql = preg_replace('/\s+/', ' ', $sql);

		return (bool) preg_match(
			'/\bCREATE\s+(?:OR\s+REPLACE\s+)?(?:ALGORITHM=\w+\s+)?(?:DEFINER=\S+\s+)?(?:SQL\s+SECURITY\s+(?:DEFINER|INVOKER)\s+)?VIEW\b/i',
			$sql
		);
	}

	/**
	 * @return void
	 * @throws Exception
	 */
	public function _database(): void {
		if (!$this->_queue_item_restore->isRestoreDatabase()) return;

		$this->getQueueItem()->updateStatus(Queue::STATUS_RESTORE_DATABASE);
		$this->getQueueItem()->updateProgress('Restoring database');
		$this->getLogController()->logMessage('Importing all database tables');

		$workspace     = $this->getQueueItem()->getWorkspace();
		$database_path = $workspace . JetBackup::SEP . Snapshot::SKELETON_DATABASE_DIRNAME;

		$visited = []; // per-run dedupe of imported dumps

		// Relax constraints for faster/safer bulk import
		$this->_mysql->query_exec('SET FOREIGN_KEY_CHECKS=0');
		$this->_mysql->query_exec('SET UNIQUE_CHECKS=0');
		$this->_mysql->query_exec('SET SQL_NOTES=0');

		try {
			// Phase 1: Import all non-view dumps
			$this->foreachCallable([$this, '_listDatabaseTables'], [], function ($i, $table_dump) use ($database_path, &$visited) {
				// Normalize dump base name -> table
				$base = $table_dump;
				if (str_ends_with($base, '.gz'))  $base = substr($base, 0, -3);
				if (str_ends_with($base, '.sql')) $base = substr($base, 0, -4);
				$table = $base;

				$dumpPath = self::_dumpPathFor($database_path, $table);
				if (!$dumpPath) return;

				// Skip views in phase 1
				if (self::_isViewDump($dumpPath)) return;

				$this->_importWithDeps($database_path, $table, $visited);

				// Progress: count only real imports
				$p = $this->getQueueItem()->getProgress();
				$p->increaseCurrentSubItem();
				$this->getQueueItem()->save();
			}, 'database_tables_phase1');

			// Phase 2: Import views
			$this->foreachCallable([$this, '_listDatabaseTables'], [], function ($i, $table_dump) use ($database_path, &$visited) {
				// Normalize dump base name -> table
				$base = $table_dump;
				if (str_ends_with($base, '.gz'))  $base = substr($base, 0, -3);
				if (str_ends_with($base, '.sql')) $base = substr($base, 0, -4);
				$table = $base;

				$dumpPath = self::_dumpPathFor($database_path, $table);
				if (!$dumpPath) return;

				// Only views in phase 2
				if (!self::_isViewDump($dumpPath)) return;

				$this->_importWithDeps($database_path, $table, $visited);

				// Progress: count only real imports
				$p = $this->getQueueItem()->getProgress();
				$p->increaseCurrentSubItem();
				$this->getQueueItem()->save();
			}, 'database_tables_phase2');

		} finally {
			// Always restore original settings
			$this->_mysql->query_exec('SET SQL_NOTES=1');
			$this->_mysql->query_exec('SET UNIQUE_CHECKS=1');
			$this->_mysql->query_exec('SET FOREIGN_KEY_CHECKS=1');
		}
	}


	/** Return ALL referenced objects for a view dump; empty for base-table dumps */
	private static function _viewReferencedObjectsFromDump(string $path): array {
		$fh = @fopen($path, 'rb');
		if (!$fh) return [];
		$chunk = fread($fh, 262144);
		fclose($fh);
		if ($chunk === false || $chunk === '') return [];

		// Strip versioned comments and normalize spaces
		$sql = preg_replace('/\/\*![0-9]+\s*/', ' ', $chunk);
		$sql = str_replace('*/', ' ', $sql);
		$sql = preg_replace('/\s+/', ' ', $sql);

		// Only for CREATE VIEW...
		if (!preg_match('/\bCREATE\s+(?:OR\s+REPLACE\s+)?(?:ALGORITHM=\w+\s+)?(?:DEFINER=\S+\s+)?(?:SQL\s+SECURITY\s+(?:DEFINER|INVOKER)\s+)?VIEW\b/i', $sql)) {
			return [];
		}

		$bases = [];

		// Handle: FROM `db`.`table`  or JOIN  `db`.`table`
		if (preg_match_all('/\b(?:FROM|JOIN)\s+`([^`]+)`\.`([^`]+)`/i', $sql, $m)) {
			foreach ($m[2] as $t) $bases[] = $t;
		}
		// Handle: FROM `table`  or JOIN `table`
		if (preg_match_all('/\b(?:FROM|JOIN)\s+`([^`]+)`(?!\.)/i', $sql, $m)) {
			foreach ($m[1] as $t) $bases[] = $t;
		}
		// Handle: FROM db.table  or JOIN db.table  (unquoted)
		if (preg_match_all('/\b(?:FROM|JOIN)\s+([A-Za-z0-9_]+)\.([A-Za-z0-9_]+)/i', $sql, $m)) {
			foreach ($m[2] as $t) $bases[] = $t;
		}
		// Handle: FROM table  or JOIN table  (unquoted)
		if (preg_match_all('/\b(?:FROM|JOIN)\s+([A-Za-z0-9_]+)\b(?!\.)/i', $sql, $m)) {
			foreach ($m[1] as $t) $bases[] = $t;
		}

		// De-dupe
		return array_values(array_unique($bases));
	}


	private static function _dumpPathFor(string $database_path, string $table): ?string {
		$p = $database_path . JetBackup::SEP . $table . '.sql';
		return is_file($p) ? $p : (is_file($p . '.gz') ? $p . '.gz' : null);
	}

	/** Normalize id for visited map: foo.sql or foo.sql.gz → foo.sql */
	private static function _dumpId(string $dumpPath): string {
		return basename(preg_replace('/\.gz$/', '', $dumpPath));
	}

	/** Recursively import a dump and all its referenced deps (from snapshot), once */
	private function _importWithDeps(string $database_path, string $table, array &$visited): void {
		$dump = self::_dumpPathFor($database_path, $table);

		// If there is no dump for this dep but it already exists in DB, proceed.
		if (!$dump) {
			if ($this->_mysql->tableExists($table, true)) return;
			throw new RestoreException("Missing dump for dependency `{$table}` required by another view.");
		}

		$id = self::_dumpId($dump);
		if (!empty($visited[$id])) return;

		// Recurse for nested deps first (only for view dumps)
		$deps = self::_viewReferencedObjectsFromDump($dump);
		foreach ($deps as $dep) {
			$this->_importWithDeps($database_path, $dep, $visited);
		}

		$this->getLogController()->logMessage(" - Importing: {$table} (" . basename($dump) . ")");
		$this->_mysql->import($dump);

		$visited[$id] = true;
	}


	/**
	 * @throws Exception
	 */
	public function _preFetchAdminUser():void {
		$tables_to_restore = $this->_listDatabaseTables();
		$matchingUser = false;

		foreach ($tables_to_restore as $table) {
			if (str_starts_with($table, $this->_getDatabasePrefix() . "users") ||
			    str_starts_with($table, $this->_getDatabasePrefix() . "usermeta")) {
				$matchingUser = true;
				break;
			}
		}

		if(!$matchingUser) return;
		$this->getLogController()->logMessage(' Fetching admin users');
		$admin_user = $this->_getAdminUser();
		$admin_user = $admin_user ? serialize($admin_user) : null;
		if(!$admin_user) return;

		$mysql_auth = $this->_fetchMySQLAuth();
		$this->_queue_item_restore->setAdminUser(Crypt::encrypt($admin_user, $mysql_auth->db_password));
		$this->getQueueItem()->save();

	}

	/**
	 * @throws RestoreException
	 */
	public function _postInsertAdminUser():void {
		if(!($admin_user = $this->_queue_item_restore->getAdminUser())) return;
		$this->getLogController()->logMessage(' Inserting admin user');
		$mysql_auth = $this->_fetchMySQLAuth();
		$admin_user = Crypt::decrypt($admin_user, $mysql_auth->db_password);
		$this->_insertAdminUser( (array) unserialize( $admin_user ));
	}


	/**
	 * @param array $user_data
	 *
	 * @return void
	 * @throws RestoreException
	 */
	private function _insertAdminUser(array $user_data): void {
		$mysql_auth = $this->_fetchMySQLAuth();
		$user_table = $mysql_auth->table_prefix . 'users';

		$query = "SHOW COLUMNS FROM `$user_table`";
		$columns = $this->_mysql->query_exec($query);

		if (!$columns || count($columns) === 0) throw new RestoreException("The table $user_table does not exist or has no columns.");
		$existing_columns = array_map(fn($column) => $column->Field, $columns);
		$filtered_user_data = array_intersect_key($user_data, array_flip($existing_columns));
		if (empty($filtered_user_data)) throw new RestoreException("No valid user data found to insert.");

		// Prepare SQL query parts
		$columns_str = implode(", ", array_keys($filtered_user_data));
		$placeholders = implode(", ", array_map(fn($key) => ":$key", array_keys($filtered_user_data)));
		$update_str = implode(", ", array_map(fn($key) => "`$key` = VALUES(`$key`)", array_keys($filtered_user_data)));

		$query = "INSERT INTO `$user_table` ($columns_str) VALUES ($placeholders) 
              ON DUPLICATE KEY UPDATE $update_str";

		$params = array_combine(array_map(fn($key) => ":$key", array_keys($filtered_user_data)), array_values($filtered_user_data));

		try {
			$this->_mysql->query_exec($query, $params);
			$this->getLogController()->logMessage("Admin user successfully inserted or updated in $user_table");

			// Set admin capabilities
			if (isset($user_data['ID'])) {
				$user_id = $user_data['ID'];
				$usermeta_table = $mysql_auth->table_prefix . 'usermeta';

				// Insert or update wp_capabilities
				$capabilities_query = "INSERT INTO `$usermeta_table` (`user_id`, `meta_key`, `meta_value`) 
                                   VALUES (:user_id, :meta_key_cap, :meta_value_cap) 
                                   ON DUPLICATE KEY UPDATE `meta_value` = :meta_value_cap";

				$params_capabilities = [
					':user_id' => $user_id,
					':meta_key_cap' => $mysql_auth->table_prefix . 'capabilities',
					':meta_value_cap' => serialize(['administrator' => true]),
				];

				$this->_mysql->query_exec($capabilities_query, $params_capabilities);
				$this->getLogController()->logMessage("Admin capabilities set for user ID $user_id");

				// Insert or update wp_user_level
				$user_level_query = "INSERT INTO `$usermeta_table` (`user_id`, `meta_key`, `meta_value`) 
                                 VALUES (:user_id, :meta_key_lvl, :meta_value_lvl) 
                                 ON DUPLICATE KEY UPDATE `meta_value` = :meta_value_lvl";

				$params_user_level = [
					':user_id' => $user_id,
					':meta_key_lvl' => $mysql_auth->table_prefix . 'user_level',
					':meta_value_lvl' => '10', // Administrator level
				];

				$this->_mysql->query_exec($user_level_query, $params_user_level);
				$this->getLogController()->logMessage("Admin user level set for user ID $user_id");
			}
		} catch (Exception $e) {
			throw new RestoreException("Failed to insert or update admin user: " . $e->getMessage());
		}
	}


	/**
	 * @return array|null
	 * @throws Exception
	 */
	private function _getAdminUser(): ?array {

		return $this->func(function(){
			$this->getLogController()->logMessage(' - Handling admin user');

			$mysql_auth = $this->_fetchMySQLAuth();
			$table_prefix = $mysql_auth->table_prefix ?? 'wp_';

			// Fetch all users with session tokens
			$query = "SELECT u.ID, u.user_login, um.meta_value AS session_tokens
              FROM `{$table_prefix}usermeta` um
              JOIN `{$table_prefix}users` u ON um.user_id = u.ID
              WHERE um.meta_key = 'session_tokens'";

			$users = $this->_mysql->query_exec($query);

			$latest_user = null;
			$latest_expiration = 0;

			// Loop through each user and find the latest session
			foreach ($users as $user) {
				$session_tokens = unserialize($user->session_tokens);

				if (!is_array($session_tokens)) continue;

				foreach ($session_tokens as $session) {
					if (isset($session['expiration']) && $session['expiration'] > $latest_expiration) {
						$latest_expiration = $session['expiration'];
						$latest_user = $user->user_login;
					}
				}
			}

			if (!$latest_user) return null;

			// Fetch full user details (Same as the original function)
			$query = "SELECT * FROM `{$table_prefix}users` WHERE `user_login` = :username";
			$params = [':username' => $latest_user];
			$user = $this->_mysql->query_exec($query, $params);

			if (!isset($user[0])) return null;
			return (array) $user[0]; // Return the full user details
		}, [], 'getAdminUser');
	}


	/**
	 * @return array
	 * @throws DBException
	 * @throws IOException
	 * @throws InvalidArgumentException
	 */
	public function _listDatabaseTables():array {

		$output = [];

		$items = $this->getSnapshot()->getItems();

		foreach ($items as $item) {
			// Skip if the item is not a database backup
			if ($item->getBackupContains() !== BackupJob::BACKUP_ACCOUNT_CONTAINS_DATABASE) continue;

			$dbName = $item->getName();
			$includedDatabases = $this->_queue_item_restore->getIncludedDatabases();
			$excludedDatabases = $this->_queue_item_restore->getExcludedDatabases();

			// Restore only included tables
			if ($includedDatabases && !in_array($dbName, $includedDatabases)) continue;

			// Exclude specific tables
			if ($excludedDatabases && in_array($dbName, $excludedDatabases)) continue;

			// Add the database backup to the output
			$output[] = basename($item->getPath());
		}


		$progress = $this->getQueueItem()->getProgress();
		$progress->setTotalSubItems(sizeof($output));
		$progress->setCurrentSubItem(0);
		$this->getQueueItem()->save();


		$this->getLogController()->logMessage('Total of ' . sizeof($output) . ' database tables found for restore');

		return $output;
	}

	/**
	 * @return void
	 * @throws RestoreException
	 */
	public function _files():void {

		if(!$this->_queue_item_restore->isRestoreHomedir()) return;

		$this->getQueueItem()->updateStatus(Queue::STATUS_RESTORE_FILES);
		$this->getQueueItem()->updateProgress('Restoring homedir files');
		$this->getLogController()->logMessage("Restoring homedir files");

		$workspace = $this->getQueueItem()->getWorkspace();

		$source = new File($workspace . JetBackup::SEP . Snapshot::SKELETON_FILES_DIRNAME);
		if(!$source->exists() || !$source->isDir()) throw new RestoreException("Can't find source directory ({$source->path()})");

		$target = new File(Factory::getWPHelper()->getWordPressHomedir());
		if(!$target->exists() || !$target->isDir()) throw new RestoreException("Can't find target directory ({$target->path()})");


		$queue = [$source->dir()];

		while($queue) {
			$dir = array_shift($queue);

			$dir_path = $dir->path;
			//$contains = 0;

			while(($entry = $dir->read()) !== false) {
				if($entry == '.' || $entry == '..') continue;

				$source_file = new File($dir_path . JetBackup::SEP . $entry);
				$target_file = new File($target->path() . JetBackup::SEP . trim(substr($source_file->path(), strlen($source->path())), JetBackup::SEP));

				$this->getLogController()->logDebug("Restoring file {$source_file->path()} to {$target_file->path()}");

				if ($source_file->isDir()) {

					switch (true) {
						case (!$target_file->exists()):
							// Case 1: Target does not exist → Create the directory
							$this->getLogController()->logDebug("[Directory Mode] Target {$target_file->path()} does not exist → Create the directory");
							mkdir($target_file->path(), $source_file->mode());
							break;

						case ($target_file->isLink() && $target_file->isDir($target_file->path())):
							// Case 2: Target is a symlink to a directory → Keep it, do nothing
							$this->getLogController()->logDebug("[Directory Mode] Target {$target_file->path()} is a symlink to a directory → Keep it, do nothing");
							break;

						case (!$target_file->isDir()):
							// Case 3: Target exists but is NOT a directory → Remove it
							$this->getLogController()->logDebug("[Directory Mode] Target {$target_file->path()} exists but is NOT a directory → Remove it");
							unlink($target_file->path());
							mkdir($target_file->path(), $source_file->mode());
							break;

						default:
							// Case 4: Target is an existing directory → Just update permissions
							$this->getLogController()->logDebug("[Directory Mode] Target {$target_file->path()} is an existing directory → Just update permissions");
							chmod($target_file->path(), $source_file->mode());
							break;
					}

					// Add directory to queue for further processing
					$queue[] = $source_file->dir();

				} else {

					switch (true) {
						case (!$target_file->exists()):
							// Case 1: Target does not exist → Move file
							$this->getLogController()->logDebug("[File Mode] Target {$target_file->path()} does not exist → Move file");
							rename($source_file->path(), $target_file->path());
							break;

						case (
							$target_file->mtime() != $source_file->mtime() ||
							$target_file->size() != $source_file->size()
						):
							// Case 2: Target exists but is outdated → Remove and replace
							$this->getLogController()->logDebug("[File Mode] Target {$target_file->path()} exists but is outdated → Remove and replace");

							unlink($target_file->path());
							rename($source_file->path(), $target_file->path());
							break;

						default:
							// Case 3: Target is identical → No need to replace, just remove source
							$this->getLogController()->logDebug("[File Mode] Target {$target_file->path()} is identical → No need to replace, just remove source");

							unlink($source_file->path());
							break;
					}
				}


			}

			$dir->close();

			// TODO we need to find better way to delete empty folders
			// delete the folder if it's empty 
			//if(!$contains) rmdir($dir_path);
		}
	}

	/**
	 * @return null
	 */
	private function _getSiteURL() {

		return $this->func(function() {

			$snapshot = $this->getSnapshot();
			foreach($snapshot->getItems() as $item) {
				if ($item->getBackupContains() != BackupJob::BACKUP_ACCOUNT_CONTAINS_FULL) continue;
				$prefix = $item->getParams()['site_url'] ?? '';
				if($prefix) return $prefix;
			}

			return '';

		}, [], '_fetchSiteURL');

	}

	/**
	 * @return string
	 */
	private function _getDatabasePrefix():string {

		return $this->func(function() {

			$snapshot = $this->getSnapshot();
			foreach($snapshot->getItems() as $item) {
				if ($item->getBackupContains() != BackupJob::BACKUP_ACCOUNT_CONTAINS_DATABASE) continue;
				$prefix = $item->getParams()['db_prefix'] ?? '';
				if($prefix) return $prefix;
			}

			return '';

		}, [], '_fetchDatabasePrefix');
	}

	/**
	 * @return void
	 * @throws RestoreException
	 * @throws Exception
	 */
	public function _postRestoreDBPrefix():void {

		if(!$this->_queue_item_restore->isRestoreDatabase()) return;

		$this->getQueueItem()->updateStatus(Queue::STATUS_RESTORE_POST_RESTORE_DB_PREFIX);
		$this->getQueueItem()->updateProgress('Post restore - DB Prefix');

		if(!($backup_prefix = $this->_getDatabasePrefix())) throw new RestoreException("Can't find backup database prefix");

		$mysql_auth = $this->_fetchMySQLAuth();
		$local_prefix = $mysql_auth->table_prefix;

		if($local_prefix == $backup_prefix) return;

		$this->getLogController()->logMessage("Local DB Prefix ($local_prefix) is different from ($backup_prefix)");

		$backup_tables = $this->_fetchBackupTables();

		if(!sizeof($backup_tables)) return;

		$this->getLogController()->logMessage("Searching for orphaned tables");
		$this->foreach($backup_tables, function($i, $table) use ($local_prefix, $backup_prefix) {
			$table_name = $local_prefix . $table;
			$table_name_old = $backup_prefix . $table;

			$this->getLogController()->logMessage("- Dropping database table `$table_name` foreign keys");
			$this->_dropForeignKeys($table_name);

			$this->getLogController()->logMessage("- Dropping database table `$table_name` if exists");
			$this->_mysql->query_exec("DROP TABLE IF EXISTS `$table_name`");

			$this->getLogController()->logMessage("- Renaming database table `$table_name_old` to `$table_name`");
			$this->_mysql->query_exec("RENAME TABLE `$table_name_old` TO `$table_name`");
		}, '_dropTables');


		$usermeta_table = $local_prefix . 'usermeta';
		$options_table = $local_prefix . 'options';

		// Search and replace prefix in the dynamically generated usermeta table
		$this->_replacePrefixInTable($usermeta_table, 'meta_key', 'umeta_id');
		$this->_replacePrefixInTable($usermeta_table, 'meta_value', 'umeta_id');

		// Search and replace prefix in the dynamically generated options table
		$this->_replacePrefixInTable($options_table, 'option_name', 'option_id');
		$this->_replacePrefixInTable($options_table, 'option_value', 'option_id');
	}

	private static function _getParseDomain($url): string {
		$compa = parse_url($url);
		$domain = $compa['host'] ?? '';
		$port = $compa['port'] ?? '';
		$path = $compa['path'] ?? '';

		$domain = preg_replace("/(www|\dww|w\dw|ww\d)\./", "", $domain);

		return $domain . ($port ? ":$port" : '') . $path;
	}

	private static function _buildSiteURL():string {
		$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? "https" : "http";
		$host = $_SERVER['HTTP_HOST'] ?? 'localhost'; // Default to localhost if missing
		$script_path = $_SERVER['SCRIPT_NAME'] ?? $_SERVER['PHP_SELF'] ?? $_SERVER['REQUEST_URI'];
		$script_dir = dirname($script_path);

		return rtrim($protocol . "://" . $host . $script_dir, '/');
	}


	public function _postRestoreDomainMigration(): void {
		$this->getQueueItem()->updateStatus(Queue::STATUS_RESTORE_POST_RESTORE_DOMAIN_MIGRATION);
		$this->getQueueItem()->updateProgress('Post restore - Domain Migration');

		$backup_domain = self::_getParseDomain($this->_getSiteURL());
		$local_domain = self::_getParseDomain(self::_buildSiteURL());

		$alternate_path = strlen(Factory::getSettingsRestore()->isRestoreAlternatePathEnabled() ? JetBackup::CRON_PUBLIC_URL : '');
		if($alternate_path) $local_domain = substr($local_domain, 0, -$alternate_path);

		if(!$local_domain) {
			$this->getLogController()->logMessage("[Error] Couldn't retrieve local domain, exiting");
			return;
		}

		if ($backup_domain === $local_domain) {
			$this->getLogController()->logMessage("Domain from backup ($backup_domain) matches local domain ($local_domain), no migration needed");
			return;
		}

		$this->foreachCallable([$this, '_fetchTables'], [], function($i, $table_details) use ($backup_domain, $local_domain) {
			$table_name = current(get_object_vars($table_details));

			$this->getLogController()->logMessage("Processing table $table_name");

			$cols = $this->_fetchTableColumns($table_name);
			$primary_key = $this->_getTablePrimaryKey($table_name);

			if (!$primary_key) {
				$this->getLogController()->logMessage("Skipping table $table_name - No primary key found.");
				return;
			}

			$this->foreach($cols, function($i, $column) use ($table_name, $backup_domain, $local_domain, $primary_key) {
				$this->getLogController()->logMessage("Checking column for table $table_name: $column");

				// Select primary key and target column
				$query = "SELECT `$primary_key`, `$column` FROM `$table_name` WHERE `$column` LIKE :old_domain";
				$rows = $this->_mysql->query_exec($query, [':old_domain' => "%$backup_domain%"]);

				if (empty($rows)) return;

				foreach ($rows as $row) {
					$original_data = $row->{$column};
					$replaced_data = $this->_changeColumnsValue($original_data, $backup_domain, $local_domain);

					if ($replaced_data !== $original_data) {
						$update_query = "UPDATE `$table_name` SET `$column` = :new_value WHERE `$primary_key` = :primary_key";
						$result = $this->_mysql->query_exec($update_query, [
							':new_value' => $replaced_data,
							':primary_key' => $row->{$primary_key}
						]);
						if ($result === false) {
							$this->getLogController()->logMessage("SQL GENERAL ERROR");
						}
						$this->getLogController()->logMessage("Updated column: $column in table: $table_name (Row ID: {$row->{$primary_key}})");

					}
				}
			}, '_fixTableColumns_' . $table_name);
		});
	}



	/**
	 * Get primary key of a table
	 */
	private function _getTablePrimaryKey(string $table_name): ?string {

		return $this->func(function($table_name){
			$query = "SHOW KEYS FROM `$table_name` WHERE Key_name = 'PRIMARY'";
			$result = $this->_mysql->query_exec($query);

			return !empty($result) ? $result[0]->Column_name : null;
		}, [$table_name], '_getTablePrimaryKey_' . $table_name);

	}


	/**
	 * @param $data
	 * @param $old_value
	 * @param $new_value
	 *
	 * @return __PHP_Incomplete_Class|array|bool|float|int|mixed|string|string[]|null
	 */
	private function _changeColumnsValue($data, $old_value, $new_value) {

		$this->getLogController()->logDebug("Processing data of type: " . gettype($data));

		switch(true) {
			case is_numeric($data) || is_bool($data) || is_null($data) || $data instanceof \__PHP_Incomplete_Class:
				return $data;

			case is_array($data):
				$this->getLogController()->logDebug("Data is array");
				foreach ($data as $key => $value) $data[$key] = $this->_changeColumnsValue($value, $old_value, $new_value);
				return $data;

			case is_object($data):
				$this->getLogController()->logDebug("Data is object");
				foreach ($data as $key => $value) $data->{$key} = $this->_changeColumnsValue($value, $old_value, $new_value);
				return $data;

			case (($unserialized = @unserialize($data, ['allowed_classes' => false])) !== false || $data == 'b:0;'):
				$this->getLogController()->logDebug("Data is serialized");
				return serialize($this->_changeColumnsValue($unserialized, $old_value, $new_value));

			case is_string($data):
				$this->getLogController()->logDebug("Performing simple replacement from $old_value to $new_value");
				$new_data = str_replace($old_value, $new_value, $data);
				if ($new_data !== $data) $this->getLogController()->logDebug("Replacement successful. New data: " . substr($new_data, 0, 50) . "...");
				else $this->getLogController()->logDebug("No replacement made in this string.");
				return $new_data;
		}

		return $data;
	}


	/**
	 * @return void
	 * Specific plugin actions needed after restore
	 * At this point we assume WordPress is loaded and healthy
	 *
	 */
	public function _postRestoreActions(): void {

		$this->getQueueItem()->updateStatus(Queue::STATUS_RESTORE_POST_RESTORE_PLUGIN_ACTIONS);
		$this->getQueueItem()->updateProgress('Post restore - Actions');
		$this->getLogController()->logMessage('Post restore - Actions');
		$actions = Factory::getSettingsIntegrations()->getInegrations();
		if(!$actions) return;

		$this->foreach($actions, function ($key, $action) {
			$method = "JetBackup\\Integrations\\Vendors\\$action";
			if(!class_exists($method)) return;
			$this->getLogController()->logMessage("Doing $action post restore actions");
			$instance = new $method();
			$instance->execute();
		}, '_postRestorePluginActions');

	}

	/**
	 * @return array
	 */
	public function _fetchBackupTables(): array {
		return $this->func(function() {
			$backup_prefix = $this->_getDatabasePrefix();

			// Only return BASE TABLEs that start with the backup prefix.
			$rows = $this->_mysql->query_exec(
				"SELECT TABLE_NAME
         FROM INFORMATION_SCHEMA.TABLES
        WHERE TABLE_SCHEMA = DATABASE()
          AND TABLE_TYPE   = 'BASE TABLE'
          AND TABLE_NAME   LIKE :p",
				[':p' => $backup_prefix . '%']
			) ?? [];

			// Return suffix (strip the prefix) to match your existing caller logic.
			return array_map(function($r) use ($backup_prefix){
				$name = is_object($r) ? $r->TABLE_NAME : (is_array($r) ? $r['TABLE_NAME'] : (string)$r);
				return substr($name, strlen($backup_prefix));
			}, $rows);
		}, [], '_fetchBackupTables');
	}

	/**
	 * @return array
	 */
	public function _fetchTables(): array {
		return $this->func(function() {
			return $this->_mysql->query_exec("SHOW TABLES");
		}, [], '_fetchTables');
	}

	/**
	 * @param $table_name
	 *
	 * @return array
	 */
	public function _fetchTableColumns($table_name): array {
		return $this->func(function() use ($table_name) {
			$output = [];
			$results = $this->_mysql->query_exec("SHOW COLUMNS FROM `$table_name`");
			foreach ($results as $row) $output[] = $row->Field;
			return $output;
		}, [], '_fetchTableColumns_' . $table_name);
	}

	/**
	 * @param $table
	 *
	 * @return void
	 * @throws RestoreException
	 */
	private function _dropForeignKeys($table) {
		try {
			// Drop foreign keys within the table
			$sql = "SELECT CONSTRAINT_NAME 
					FROM information_schema.TABLE_CONSTRAINTS 
					WHERE CONSTRAINT_TYPE = 'FOREIGN KEY' 
					AND TABLE_SCHEMA = DATABASE() 
					AND TABLE_NAME = :table_name";
			$constraints = $this->_mysql->query_exec($sql, [':table_name' => $table]);

			foreach ($constraints as $constraint) {
				$constraint_name = $constraint->CONSTRAINT_NAME;
				$alter_query = "ALTER TABLE `$table` DROP FOREIGN KEY `$constraint_name`";
				$this->getLogController()->logMessage("Dropping foreign key: $constraint_name from table: $table");
				$this->_mysql->query_exec($alter_query);
			}

			// Drop foreign keys that reference the table from other tables
			$sql = "SELECT TABLE_NAME, CONSTRAINT_NAME 
					FROM information_schema.KEY_COLUMN_USAGE 
					WHERE REFERENCED_TABLE_SCHEMA = DATABASE() 
					AND REFERENCED_TABLE_NAME = :table_name";
			$constraints = $this->_mysql->query_exec($sql, [':table_name' => $table]);

			foreach ($constraints as $constraint) {
				$referencing_table = $constraint->TABLE_NAME;
				$constraint_name = $constraint->CONSTRAINT_NAME;
				$alter_query = "ALTER TABLE `$referencing_table` DROP FOREIGN KEY `$constraint_name`";
				$this->getLogController()->logMessage("Dropping foreign key: $constraint_name from table: `$referencing_table` that references `$table`");
				$this->_mysql->query_exec($alter_query);
			}
		} catch ( Exception $e) {
			$this->getLogController()->logError("Error dropping foreign keys for table $table: " . $e->getMessage());
			throw new RestoreException($e->getMessage());
		}
	}

	/**
	 * @param $table_name
	 * @param $column_name
	 * @param $id_column
	 *
	 * @return void
	 * @throws Exception
	 */
	private function _replacePrefixInTable($table_name, $column_name, $id_column) {
		$this->getLogController()->logMessage("Starting prefix replacement in $table_name.$column_name");

		$backup_prefix = $this->_getDatabasePrefix();
		$mysql_auth = $this->_fetchMySQLAuth();

		$select_query = "SELECT `$id_column`, `$column_name` FROM `$table_name` WHERE TRIM(`$column_name`) LIKE :old_prefix";
		$this->getLogController()->logMessage("Running SQL: $select_query with :old_prefix => '$backup_prefix%'");

		$rows = $this->_mysql->query_exec($select_query, [':old_prefix' => $backup_prefix . '%']);

		$this->getLogController()->logMessage("Number of rows found: " . count($rows));

		if (!$rows) {
			$this->getLogController()->logMessage("No rows found needing prefix replacement in $table_name.$column_name");
			return;
		}

		foreach ($rows as $row) {
			$original_data = $row->{$column_name};
			$new_data = str_replace($backup_prefix, $mysql_auth->table_prefix, $original_data);

			// Log the raw data
			$this->getLogController()->logMessage("Row data for $id_column: {$row->{$id_column}} => $original_data");

			// Log the before and after values
			$this->getLogController()->logMessage("Original value in {$table_name}.{$column_name} for {$id_column}: {$row->{$id_column}} is: {$original_data}");
			$this->getLogController()->logMessage("New value after replacement in {$table_name}.{$column_name} for {$id_column}: {$row->{$id_column}} is: {$new_data}");

			if ($new_data !== $original_data) {
				$update_query = "UPDATE `{$table_name}` SET `{$column_name}` = :new_value WHERE `{$id_column}` = :id";
				$this->getLogController()->logMessage("Updating {$table_name}.{$column_name} for {$id_column}: {$row->{$id_column}}");
				$this->getLogController()->logMessage("Executing update query: {$update_query} with new_value: {$new_data}, id: {$row->{$id_column}}");

				$this->_mysql->query_exec($update_query, [
					':new_value' => $new_data,
					':id' => $row->{$id_column},
				]);
				$this->getLogController()->logMessage("Updated {$table_name}.{$column_name} for {$id_column}: {$row->{$id_column}} successfully");
			} else {
				$this->getLogController()->logMessage("No change required for {$id_column}: {$row->{$id_column}}");
			}
		}
	}

	public function _postRestoreHealthCheck():void {

		$this->getQueueItem()->updateStatus(Queue::STATUS_RESTORE_POST_RESTORE_HEALTH_CHECK);
		$this->getQueueItem()->updateProgress('Post restore - Health Check');
		$this->getLogController()->logMessage('Post restore - Health Check');
		$this->func([$this, '_checkWPHealth']);
		$this->func([$this, '_checkDatabaseConnection']);
		$this->func([$this, '_checkConfig']);
		$this->func([$this, '_flushRewriteRules']);
	}


	public function _checkConfig() {

		$this->getLogController()->logMessage('Testing wp-config file');

		$wp_config_file = JetBackup::WP_ROOT_PATH . JetBackup::SEP . 'wp-config.php';

		if (!file_exists($wp_config_file)) {
			$this->getLogController()->logError('wp-config.php is missing');
			return;
		}

		$required_constants = ['DB_NAME', 'DB_USER', 'DB_PASSWORD', 'DB_HOST'];
		foreach ($required_constants as $constant) {
			if (!defined($constant)) $this->getLogController()->logError("Constant $constant is not defined in wp-config.php");
		}

		$this->getLogController()->logMessage('wp-config file OK');
	}

	public function _flushRewriteRules() {
		if (!function_exists('flush_rewrite_rules')) return;
		if (function_exists('wp_cache_flush')) wp_cache_flush();
		$this->getLogController()->logMessage('Executing flush_rewrite_rules immediately');
		flush_rewrite_rules(true);
	}

	public function _checkDatabaseConnection() {
		global $wpdb;
		$this->getLogController()->logMessage('Testing Database Connection');

		try {
			if (!isset($wpdb) || empty($wpdb)) $this->_loadWP();
			$wpdb->get_results("SELECT 1");
			$this->getLogController()->logMessage('Database Connection OK');
		} catch (Exception $e) {
			$this->getLogController()->logError($e->getMessage());
		}
	}

	/**
	 * @return void
	 *
	 * Some hosts use symlinked mu-plugins pointing to shared system directories.
	 * During migration, these links break, causing missing files and errors.
	 *
	 */
	public function _validateMUPlugins() {

		$mu_plugins_path = self::MU_PLUGINS_PATH;

		$this->getLogController()->logMessage("Validating MU Plugins folder: {$mu_plugins_path}");
		if (!is_dir($mu_plugins_path)) {
			$this->getLogController()->logMessage("MU Plugins folder does not exist: {$mu_plugins_path}, nothing to do...");
			return;
		}

		$mu_plugins = glob($mu_plugins_path . JetBackup::SEP . '*.php');
		$broken_links = [];

		if (empty($mu_plugins)) {
			$this->getLogController()->logMessage("No MU Plugins found.");
			return;
		}

		foreach ($mu_plugins as $plugin) {
			if (is_link($plugin) && !file_exists($plugin)) $broken_links[] = $plugin;
		}

		if (empty($broken_links)) {
			$this->getLogController()->logMessage("No Broken MU Plugins found, folder is valid.");
			return;
		}

		// Remove broken links
		foreach ($broken_links as $link) {
			$this->getLogController()->logError("Broken MU Plugin found: {$link}");
			if (@unlink($link)) {
				$this->getLogController()->logMessage("Successfully removed: {$link}");
			} else {
				$this->getLogController()->logError("Failed to remove: {$link}");
			}
		}

		$this->getLogController()->logDebug("Broken links removed: " . json_encode($broken_links));

	}



	public function _checkWPHealth() {

		try {
			$this->_loadWP();

			// Ensure WordPress is fully loaded by checking a key constant or function
			if (!function_exists('wp_get_current_user'))
				throw new Exception('WordPress did not fully load. A fatal error may have occurred.');

		} catch(Exception $e) {
			$this->getLogController()->logError('Failed to load WordPress: ' . $e->getMessage());

			$this->func(function() {

				$this->getLogController()->logMessage('Scanning and disabling all active plugins');

				$plugin_dirs = glob(self::PLUGINS_PATH . JetBackup::SEP . '*', GLOB_ONLYDIR);

				$this->getLogController()->logDebug('Found plugin directories: ' . json_encode($plugin_dirs));

				$this->foreach($plugin_dirs, function($i, $plugin_dir) {
					$this->_disablePlugin(basename($plugin_dir));
				}, '_disableAllPlugins');

			}, [], '_disableAllPlugins');

			$disabled_plugins = glob(self::PLUGINS_PATH . JetBackup::SEP . '*_disabled_' . $this->getQueueItem()->getUniqueId(), GLOB_ONLYDIR);

			$this->foreach($disabled_plugins, function($i, $plugin_dir) {

				$plugin_name = basename($plugin_dir);

				$this->_enablePlugin($plugin_name);

				try {
					$this->_loadWP();
				} catch(Exception $e) {
					$this->getLogController()->logError('Fatal error detected when enabling plugin: ' . $plugin_name . ' - ' . $e->getMessage());
					$this->_disablePlugin($plugin_name);
				}

			}, '_checkPluginsOneByOne');
		}
	}

	public function _enablePlugin($plugin_name):void {

		$plugin_dir = self::PLUGINS_PATH . JetBackup::SEP . $plugin_name;
		$plugin_dir_disabled = $plugin_dir . '_disabled_' . $this->getQueueItem()->getUniqueId();

		if(!file_exists($plugin_dir_disabled)) return;

		$this->getLogController()->logMessage("Enabling plugin: $plugin_name");

		if (!is_dir($plugin_dir_disabled)) {
			$this->getLogController()->logError("Failed to rename plugin directory $plugin_dir_disabled");
			return;
		}

		rename($plugin_dir_disabled, $plugin_dir);
		$this->getLogController()->logMessage("Renamed plugin directory from $plugin_dir_disabled to $plugin_dir");

		if (in_array($plugin_name, self::CACHE_PLUGINS)) {

			$cache_file = JetBackup::WP_ROOT_PATH . JetBackup::SEP . 'wp-content' . JetBackup::SEP . 'object-cache.php';
			if (file_exists($cache_file)) {
				$this->getLogController()->logMessage("Renamed $cache_file to enable it");
				rename($cache_file . '_disabled_' . $this->getQueueItem()->getUniqueId(), $cache_file);
			}
		}
	}

	// disable plugin from the db will not cover some fatal errors, we are forcing it by changing the name
	private function _disablePlugin($plugin_name):void {

		$plugin_dir = self::PLUGINS_PATH . JetBackup::SEP . $plugin_name;
		$plugin_dir_disabled = $plugin_dir . '_disabled_' . $this->getQueueItem()->getUniqueId();
		$plugin_file = $plugin_dir . JetBackup::SEP . $plugin_name . '.php';

		if(!file_exists($plugin_file)) return;

		$this->getLogController()->logMessage("Disabling plugin: $plugin_name");

		if(in_array($plugin_name, self::PROTECTED_PLUGINS)) {
			$this->getLogController()->logMessage("Skipped disabling protected plugin: $plugin_name");
			return;
		}

		if(file_exists($plugin_dir_disabled)) rename($plugin_dir_disabled, $plugin_dir_disabled . '_' . Util::generateRandomString());

		if (!is_dir($plugin_dir)) {
			$this->getLogController()->logError("Failed to rename plugin directory $plugin_dir");
			return;
		}

		rename($plugin_dir, $plugin_dir_disabled);
		$this->getLogController()->logMessage("Renamed plugin directory from $plugin_dir to $plugin_dir_disabled");

		if (in_array($plugin_name, self::CACHE_PLUGINS)) {
			$this->getLogController()->logMessage("Problematic plugin is object cache, disabling 'object-cache.php' file");

			$cache_file = JetBackup::WP_ROOT_PATH . JetBackup::SEP . 'wp-content' . JetBackup::SEP . 'object-cache.php';
			if (file_exists($cache_file)) {
				$this->getLogController()->logMessage("Renamed $cache_file to disable it");
				rename($cache_file, $cache_file . '_disabled_' . $this->getQueueItem()->getUniqueId());
			}
		}

		//$this->_disabled_plugins[] = $plugin_name;
	}


	/**
	 * @return void
	 * @throws Exception
	 */
	private function _loadWP():void {
		ob_start(); // Start output buffering

		$load_file = JetBackup::WP_ROOT_PATH . JetBackup::SEP . 'wp-load.php';

		try {

			if (file_exists($load_file)) {
				require_once($load_file);

				// Temporary disable cron events
				if(!defined('DISABLE_WP_CRON')) define('DISABLE_WP_CRON', true);

				$output = ob_get_clean(); // Get buffered output

				// Log the output, even if it seems normal
				if (!empty($output)) $this->getLogController()->logMessage('Output from WordPress load: ' . $output);
				$this->getLogController()->logMessage('WordPress ecosystem loaded successfully');

			} else {
				throw new Exception('wp-load.php not found in the specified WordPress root.');
			}

		} catch (\Throwable $e) {
			$output = ob_get_clean(); // Clean the buffer in case of an error
			$this->getLogController()->logError('Failed loading WordPress: ' . $e->getMessage() . ' | Buffered Output: ' . $output);
			throw new Exception('Failed loading WordPress: ' . $e->getMessage());
		}
	}
}