<?php
/**
 * Akeeba Backup Restoration Script
 *
 * @package   brs
 * @copyright Copyright (c)2025 Nicholas K. Dionysopoulos / Akeeba Ltd
 * @license   GNU General Public License version 3, or later
 */

namespace Akeeba\BRS\Platform\Model;

defined('_AKEEBA') or die();

use Akeeba\BRS\Framework\Database\DatabaseDriverInterface;
use Akeeba\BRS\Framework\Mvc\Model;
use Akeeba\BRS\Platform\Engine\PartStatus;
use Akeeba\BRS\Platform\Engine\ReplacementEngine;
use Akeeba\BRS\Platform\Engine\RowFilter\WordPressOptions;
use Psr\Log\LogLevel;
use Throwable;

/**
 * Model for the Replace Data step on WordPress.
 *
 * @since  10.0
 */
class Replacedata extends Model
{
	/**
	 * The replacements to apply.
	 *
	 * @var   array
	 * @since 10.0
	 */
	private $replacements = [];

	/**
	 * Reference to the database driver object.
	 *
	 * @var   DatabaseDriverInterface
	 * @since 10.0
	 */
	private $db = null;

	/**
	 * Get the database driver object.
	 *
	 * @return  DatabaseDriverInterface
	 * @since   10.0
	 */
	public function getDbo(): DatabaseDriverInterface
	{
		if (is_object($this->db))
		{
			return $this->db;
		}

		$options = $this->getDatabaseConnectionOptions();
		$name    = $options['driver'];

		unset($options['driver']);

		$this->db = $this->container->get('db')->driver($name, $options);
		$this->db->setUTF();

		return $this->db;
	}

	/**
	 * Is this a multisite installation?
	 *
	 * @return  bool  True if this is a multisite installation
	 * @since   10.0
	 */
	public function isMultisite(): bool
	{
		return $this->getContainer()->get('mvcFactory')
			->model('Configuration')
			->get('multisite', false);
	}

	/**
	 * Returns all the database tables which are not part of the WordPress core.
	 *
	 * @return  array<string>
	 * @since   10.0
	 */
	public function getNonCoreTables(): array
	{
		// Get a list of core tables
		$coreTables = $this->getCoreTables();

		// Now get a list of non-core tables
		try
		{
			$db = $this->getDbo();
		}
		catch (\Exception $e)
		{
			return $coreTables;
		}

		$allTables = $db->getTableList();

		$result = [];

		foreach ($allTables as $table)
		{
			if (in_array($table, $coreTables))
			{
				continue;
			}

			$result[] = $table;
		}

		return $result;
	}

	/**
	 * Get the core WordPress tables.
	 *
	 * Content in these tables is always being replaced during restoration.
	 *
	 * @return  array<string>
	 * @since   10.0
	 */
	public function getCoreTables(): array
	{
		// Core WordPress tables (single site)
		$coreTables = [
			'#__commentmeta',
			'#__comments',
			'#__links',
			'#__options',
			'#__postmeta',
			'#__posts',
			'#__term_relationships',
			'#__term_taxonomy',
			'#__wp_termmeta',
			'#__terms',
			'#__usermeta',
			'#__users',
		];

		try
		{
			$db = $this->getDbo();
		}
		catch (\Throwable $e)
		{
			return $coreTables;
		}

		// If we have a multisite installation we need to add the per-blog tables as well
		if ($this->isMultisite())
		{
			$additionalTables = ['#__blogmeta', '#__blogs', '#__site', '#__sitemeta'];

			$config     = $this->getContainer()->get('mvcFactory')->model('Configuration');
			$mainBlogId = $config->get('blog_id_current_site', 1);

			$map     = $this->getMultisiteMap($db);
			$siteIds = array_keys($map);

			foreach ($siteIds as $id)
			{
				if ($id == $mainBlogId)
				{
					continue;
				}

				foreach ($coreTables as $table)
				{
					$additionalTables[] = '#__' . $id . '_' . substr($table, 3);
				}
			}

			$coreTables = array_merge($coreTables, $additionalTables);
		}

		// Replace the meta-prefix with the real prefix
		return array_map(
			function ($v) use ($db) {
				return $db->replacePrefix($v);
			}, $coreTables
		);
	}

	/**
	 * Data in these tables shouldn't be replaced by default, since they are known to create issues (very long fields)
	 *
	 * @return  array<string>
	 * @since   10.0
	 */
	public function getDeselectedTables(): array
	{
		try
		{
			$db = $this->getDbo();
		}
		catch (\Throwable $e)
		{
			return [];
		}

		$blacklist = [
			'#__itsec_distributed_storage',     // iTheme Security Pro: site files fingerprints
			'#__itsec_logs',                    // iTheme Security Pro: security exceptions log
		];

		// Replace the meta-prefix with the real prefix
		return array_map(
			function ($v) use ($db) {
				return $db->replacePrefix($v);
			}, $blacklist
		);
	}

	/**
	 * Get the data replacement values
	 *
	 * @param   bool  $fromRequest  Should I override session data with those from the request?
	 * @param   bool  $force        True to forcibly load the default replacements.
	 *
	 * @return  array
	 */
	public function getReplacements(bool $fromRequest = false, bool $force = false): array
	{
		$session      = $this->getContainer()->get('session');
		$replacements = (array) $session->get('dataReplacements', []);

		if (empty($replacements))
		{
			$replacements = [];
		}

		if ($fromRequest)
		{
			$replacements = [];

			$keys   = trim($this->getState('replaceFrom', ''));
			$values = trim($this->getState('replaceTo', ''));

			if (!empty($keys))
			{
				$keys   = explode("\n", $keys);
				$values = explode("\n", $values);

				foreach ($keys as $k => $v)
				{
					if (!isset($values[$k]))
					{
						continue;
					}

					$replacements[$v] = $values[$k];
				}
			}
		}

		if (empty($replacements) || $force)
		{
			$replacements = $this->getDefaultReplacements();
		}

		/**
		 * I must not replace / with something else, e.g. /foobar. This would cause URLs such as
		 * http://www.example.com/something to be replaced with a monstrosity like
		 * http:/foobar/foobar/www.example.com/foobarsomething which breaks the site :s
		 *
		 * The same goes for the .htaccess file, where /foobar would be added in random places,
		 * breaking the site.
		 */
		if (isset($replacements['/']))
		{
			unset($replacements['/']);
		}

		$session->set('dataReplacements', $replacements);

		return $replacements;
	}

	/**
	 * Post-processing for the #__blogs table of multisite installations
	 *
	 * @return  void
	 * @since   10.0
	 */
	public function updateMultisiteTables(): void
	{
		// Get the new base domain and base path

		$config                     = $this->getContainer()->get('mvcFactory')->model('Configuration');
		$new_url                    = $config->get('homeurl');
		$newUri                     = $this->getContainer()->get('uri')->instance($new_url);
		$newDomain                  = $newUri->getHost();
		$newPath                    = $newUri->getPath();
		$old_url                    = $config->get('oldurl');
		$oldUri                     = $this->getContainer()->get('uri')->instance($old_url);
		$oldDomain                  = $oldUri->getHost();
		$oldPath                    = $oldUri->getPath();
		$useSubdomains              = $config->get('subdomain_install', 0);
		$changedDomain              = $newUri->getHost() != $oldDomain;
		$changedPath                = $oldPath != $newPath;
		$convertSubdomainsToSubdirs = $this->mustConvertSudomainsToSubdirs($config, $changedPath, $newDomain);

		$db = $this->getDbo();

		/**
		 * Update #__blogs
		 *
		 * This contains a map of blog IDs to their domain and path (stored separately).
		 */
		$query = $db->getQuery(true)
			->select('*')
			->from($db->qn('#__blogs'));

		try
		{
			$blogs = $db->setQuery($query)->loadObjectList();
		}
		catch (Throwable $e)
		{
			$blogs = [];
		}

		$defaultBlogId = 1;

		foreach ($blogs as $blog)
		{
			if ($blog->blog_id == $defaultBlogId)
			{
				// Default site: path must match the site's installation path (e.g. /foobar/)
				$blog->path   = '/' . trim($newPath, '/') . '/';
				$blog->domain = $newUri->getHost();
			}
			/**
			 * Converting blog1.example.com to www.example.net/myfolder/blog1 (multisite subdomain installation in the
			 * site's root TO multisite subfolder installation in a subdirectory)
			 */
			elseif ($convertSubdomainsToSubdirs)
			{
				// Extract the subdomain WITHOUT the trailing dot
				$subdomain = substr($blog->domain, 0, -strlen($oldDomain) - 1);

				// Step 1. Domain: Convert old subdomain (blog1.example.com) to new full domain (www.example.net)
				$blog->domain = $newUri->getHost();

				// Step 2. Path: Replace the old path (/) with the new path + slug (/mysite/blog1).
				$blogPath   = trim($newPath, '/') . '/' . trim($subdomain, '/') . '/';
				$blog->path = '/' . ltrim($blogPath, '/') . '/';

				if ($blog->path == '//')
				{
					$blog->path = '/';
				}
			}
			/**
			 * Converting blog1.example.com to blog1.example.net (keep multisite subdomain installation, change the
			 * domain name)
			 */
			elseif ($useSubdomains && $changedDomain)
			{
				// Change domain (extract subdomain a.k.a. alias, append $newDomain to it)
				$subdomain    = substr($blog->domain, 0, -strlen($oldDomain));
				$blog->domain = $subdomain . $newDomain;
			}
			/**
			 * Convert subdomain installations when EITHER the domain OR the path have changed. E.g.:
			 *  www.example.com/blog1   to  www.example.net/blog1
			 * OR
			 *  www.example.com/foo/blog1   to  www.example.com/bar/blog1
			 * OR
			 *  www.example.com/foo/blog1   to  www.example.net/bar/blog1
			 */
			elseif ($changedDomain || $changedPath)
			{
				if ($changedDomain)
				{
					// Update the domain
					$blog->domain = $newUri->getHost();
				}

				if ($changedPath)
				{
					// Change $blog->path (remove old path, keep alias, prefix it with new path)
					$path       = (strpos($blog->path, $oldPath) === 0) ? substr($blog->path, strlen($oldPath))
						: $blog->path;
					$blog->path = '/' . trim($newPath . '/' . ltrim($path, '/'), '/');
				}
			}

			// For every record, make sure the path column ends in forward slash (required by WP)
			$blog->path = rtrim($blog->path, '/') . '/';

			// Save the changed record
			try
			{
				$db->updateObject('#__blogs', $blog, ['blog_id', 'site_id']);
			}
			catch (Throwable $e)
			{
				// If we failed to save the record just skip over to the next one.
			}
		}

		/**
		 * Update #__site
		 *
		 * This contains the main site address in its one and only record. The same address which is defined as a
		 * constant in wp-config.php and stored in the #__options and #__sitemeta table.
		 *
		 * I am not making up preposterous claims. This is what WordPress itself describes in its official Codex, see
		 * https://codex.wordpress.org/Database_Description#Multisite_Table_Overview Yeah, I know it's a pointless
		 * table with pointless data. Yet, if it's not replaced properly the whole thing goes bang like a firecracker!
		 */
		$query = $db->getQuery(true)
			->select('*')
			->from($db->qn('#__site'))
			->where($db->qn('id') . ' = ' . $db->q('1'));

		try
		{
			$siteObject = $db->setQuery($query)->loadObject();
		}
		catch (Throwable $e)
		{
			return;
		}

		// Default site: path must match the site's installation path (e.g. /foobar/)
		$siteObject->path   = '/' . trim($newPath, '/') . '/';
		$siteObject->domain = $newUri->getHost();

		// Save the changed record
		try
		{
			$db->updateObject('#__site', $siteObject, ['id']);
		}
		catch (Throwable $e)
		{
			// If we failed to save the record no problem, everything will crash and burn.
		}
	}

	/**
	 * Initialize the data replacement engine.
	 *
	 * @return  ReplacementEngine
	 * @since   10.0
	 */
	public function init(): ReplacementEngine
	{
		// Create a new engine
		$engine = new ReplacementEngine($this->getContainer(), $this->getDbo());

		$engine->reset();

		/**
		 * Get the excluded tables.
		 *
		 * All core WordPress tables are always included. We only let the user which of the non-core tables should also
		 * be included in replacements. Therefore, any non-core table NOT explicitly included by the user has to be
		 * excluded from the replacement.
		 */
		$coreTables    = $this->getCoreTables();
		$extraTables   = $this->getState('extraTables', []);
		$includeTables = array_merge($coreTables, $extraTables);

		// If we are under CLI we need to replace everything, everywhere — as long as we share the same prefix
		if (!array_key_exists('REQUEST_METHOD', $_SERVER))
		{
			$dbBrs         = $this->getDbo();
			$prefix        = $dbBrs->getPrefix();
			$includeTables = array_filter(
				$dbBrs->getTableList(),
				function ($tableName) use ($prefix) {
					return strpos($tableName, $prefix) === 0;
				}
			);
		}

		// Apply the timing settings
		$minExec = $this->getState('min_exec', 0);
		$maxExec = $this->getState('max_exec', 3);
		$bias    = $this->getState('runtime_bias', 75);

		$engine->applyTimerSettings($minExec, $maxExec, $bias);

		// I must always exclude the following tables which are handled in the updateMultisiteTables() method
		$prefix = $this->getDbo()->getPrefix();

		$excludedTables = [
			$prefix . 'blogs',
			$prefix . 'site',
		];

		// Exclude post meta keys from replacements (these never contain any replaceable values)
		$excludedFields = [
			$prefix . 'postmeta' => ['meta_key'],
		];

		// Unless we're specifically told otherwise, exclude all post GUIDs from replacements.
		$replaceGUID = $this->getState('replaceguid', 0);

		if (!$replaceGUID)
		{
			$excludedFields[$prefix . 'posts'] = ['guid'];
		}

		// Excluded fields for multi-site installations
		if ($this->isMultisite())
		{
			$dbBrs   = $this->getDbo();
			$map     = $this->getMultisiteMap($dbBrs);
			$siteIds = array_keys($map);

			foreach ($siteIds as $siteId)
			{
				if ($siteId == 1)
				{
					continue;
				}

				$excludedFields[$prefix . $siteId . '_postmeta'] = ['meta_key'];

				if (!$replaceGUID)
				{
					$excludedFields[$prefix . $siteId . '_posts'] = ['guid'];
				}
			}
		}

		// Set up the logger's minimum severity
		$logger = $engine->getLogger();

		try
		{
			$logger->setMinimumSeverity(LogLevel::DEBUG);
		}
		catch (\Throwable $e)
		{
			// No-op
		}

		$logger->debug('========== Data replacement initialisation start ==========');

		$engine->initialize(
			$includeTables,
			$excludedTables,
			$excludedFields,
			$this->getReplacements(true, false),
			$this->getState('batchSize', 100),
			$this->getState('column_size', 1048576)
		);


		$logger->debug('========== Data replacement initialisation end ==========');

		$this->setState('more', true);

		return $engine;
	}

	/**
	 * Step the Akeeba Replace engine.
	 *
	 * The engine steps through for the allowed period, or until we are done. Then, it returns the status result back to
	 * its caller.
	 *
	 * @return  ReplacementEngine
	 * @since   10.0
	 */
	public function step(): ReplacementEngine
	{
		$db     = $this->getDbo();
		$engine = new ReplacementEngine($this->getContainer(), $db);
		$timer  = $engine->getTimer();
		$logger = $engine->getLogger();

		$logger->debug('========== Data replacement step start ==========');

		while ($timer->getTimeLeft() > 0.01)
		{
			$more = $engine->step(
				[
					new WordPressOptions($this->getContainer(), $db),
				]
			);

			if (!$more)
			{
				break;
			}
		}

		$this->setState('more', $more);

		$logger->debug('========== Data replacement step end ==========');

		return $engine;
	}

	/**
	 * Update the GUIDs of the uploads
	 *
	 * This is necessary because WordPress is using these GUIDs to insert the uploads into posts... even though the
	 * GUIDs are meant to be used as unique identifiers, not actual URLs. I guess it's too much asking them to make up
	 * their minds.
	 *
	 * @return  void
	 * @since   10.0
	 */
	public function updateAttachmentGUIDs(): void
	{
		$config = $this->getContainer()->get('mvcFactory')->model('Configuration');

		$old_url = $config->get('oldurl');
		$oldUri  = $this->getContainer()->get('uri')->instance($old_url);
		$old_url = rtrim($oldUri->toString(), '/') . '/';

		$new_url = $config->get('homeurl');
		$newUri  = $this->getContainer()->get('uri')->instance($new_url);
		$new_url = rtrim($newUri->toString(), '/') . '/';

		try
		{
			$db    = $this->getDbo();
			$query = $db->getQuery(true)
				->update($db->qn('#__posts'))
				->set(
					$db->qn('guid') . ' = REPLACE(' .
					$db->qn('guid') . ',' .
					$db->qn($old_url) . ',' .
					$db->qn($new_url) .
					')'
				)->where($db->qn('post_type') . ' = ' . $db->q('attachment'));
			$db->setQuery($query)->execute();
		}
		catch (Throwable $e)
		{
			// No problem if this fails.
		}
	}

	/**
	 * Updates known files that are storing absolute paths inside them
	 *
	 * @return  void
	 * @since   10.0
	 */
	public function updateFiles(): void
	{
		$paths = $this->getContainer()->get('paths');
		// Note: DO NOT APPLY ANYTHING TO .htaccess OR htaccess.bak; IT WILL BE DONE IN THE NEXT STEP.
		$files = [
			// I'll try to apply the changes to those files and their "backup" counterpart
			$paths->get('site') . '/.user.ini.bak',
			$paths->get('site') . '/.user.ini',
			$paths->get('site') . '/php.ini',
			$paths->get('site') . '/php.ini.bak',
			// Wordfence is storing the absolute path inside their file. We need to replace this or the site will crash.
			$paths->get('site') . '/wordfence-waf.php',
		];

		foreach ($files as $file)
		{
			if (!file_exists($file))
			{
				continue;
			}

			$contents = file_get_contents($file);

			foreach ($this->replacements as $from => $to)
			{
				$contents = str_replace($from, $to, $contents);
			}

			file_put_contents($file, $contents);
		}
	}

	/**
	 * Update the wp-config.php file. Required for multisite installations.
	 *
	 * @return  bool
	 * @since   10.0
	 */
	public function updateWPConfigFile(): bool
	{
		$config = $this->getContainer()->get('mvcFactory')->model('Configuration');

		// Update the base directory, if present
		$base = $config->get('base', null);

		if (!is_null($base))
		{
			$base = '/' . trim($config->getNewBasePath(), '/');
			$config->set('base', $base);
		}

		// If I have to convert subdomains to subdirs then I need to update SUBDOMAIN_INSTALL as well
		$old_url = $config->get('oldurl');
		$new_url = $config->get('homeurl');

		$oldUri = $this->getContainer()->get('uri')->instance($old_url);
		$newUri = $this->getContainer()->get('uri')->instance($new_url);

		$newDomain = $newUri->getHost();

		$newPath = $newUri->getPath();
		$newPath = empty($newPath) ? '/' : $newPath;
		$oldPath = $config->get('path_current_site', $oldUri->getPath());

		$replacePaths = $oldPath != $newPath;

		$mustConvertSubdomains = $this->mustConvertSudomainsToSubdirs($config, $replacePaths, $newDomain);

		if ($mustConvertSubdomains)
		{
			$config->set('subdomain_install', 0);
		}

		// Get the wp-config.php file and try to save it
		$paths = $this->getContainer()->get('paths');

		if (!$config->writeConfig($paths->get('site') . '/wp-config.php'))
		{
			return false;
		}

		return true;
	}

	/**
	 * Get the default replacements for the stored URLs.
	 *
	 * @return  array
	 * @since   10.0
	 */
	public function getDefaultURLReplacements(): array
	{
		$replacements = [];

		$config = $this->getContainer()->get('mvcFactory')->model('Configuration');

		// Main site's URL
		$newReplacements = $this->getDefaultReplacementsForMainSite($config, false);
		$replacements    = array_merge($replacements, $newReplacements);

		// Multisite's URLs
		$newReplacements = $this->getDefaultReplacementsForMultisite($config);
		$replacements    = array_merge($replacements, $newReplacements);

		if (empty($replacements))
		{
			return [];
		}

		// Remove replacements where from is just a slash or empty
		$temp = [];

		foreach ($replacements as $from => $to)
		{
			$trimFrom = trim($from, '/\\');

			if (empty($trimFrom))
			{
				continue;
			}

			$temp[$from] = $to;
		}

		$replacements = $temp;

		if (empty($replacements))
		{
			return [];
		}

		// Find http[s]:// from/to and create replacements with just :// as the protocol
		$temp = [];

		foreach ($replacements as $from => $to)
		{
			$replaceFrom = ['http://', 'https://'];
			$replaceTo   = ['://', '://'];
			$from        = str_replace($replaceFrom, $replaceTo, $from);
			$to          = str_replace($replaceFrom, $replaceTo, $to);
			$temp[$from] = $to;
		}

		$replacements = $temp;

		if (empty($replacements))
		{
			return [];
		}

		// Go through all replacements and create a RegEx variation
		$temp = [];

		foreach ($replacements as $from => $to)
		{
			$from = $this->escape_string_for_regex($from);
			$to   = $this->escape_string_for_regex($to);

			if (array_key_exists($from, $replacements))
			{
				continue;
			}

			$temp[$from] = $to;
		}

		$replacements = array_merge_recursive($replacements, $temp);

		// Return the resulting replacements table
		return $replacements;
	}

	/**
	 * Updates entries in the #__options table.
	 *
	 * @return  void
	 * @since   10.0
	 */
	public function updateSiteOptions(): void
	{
		// ========== Get the WordPress options to update ==========
		$configModel = $this->getContainer()->get('mvcFactory')->model('Configuration');

		$siteOptions = [
			'siteurl' => $configModel->get('siteurl', ''),
			'home'    => $configModel->get('homeurl', ''),
		];

		$siteOptions['home'] = empty($siteOptions['home']) ? $siteOptions['siteurl'] : $siteOptions['home'];

		// ========== Connect to the main site's database and update entries ==========
		$dbModel        = $this->getContainer()->get('mvcFactory')->model('Database');
		$dbKeys         = $dbModel->getDatabaseNames();
		$firstDbKey     = array_shift($dbKeys);
		$connectionVars = $dbModel->getDatabaseInfo($firstDbKey);

		try
		{
			$name    = $connectionVars->dbtype;
			$options = [
				'database' => $connectionVars->dbname,
				'select'   => 1,
				'host'     => $connectionVars->dbhost,
				'user'     => $connectionVars->dbuser,
				'password' => $connectionVars->dbpass,
				'prefix'   => $connectionVars->prefix,
			];

			$db = $this->getContainer()->get('db')->driver($name, $options);
		}
		catch (Throwable $exc)
		{
			// Can't connect to the DB. Your site will be borked but at least I tried :(
			return;
		}

		foreach ($siteOptions as $key => $value)
		{
			try
			{
				$query = $db->getQuery(true)
					->update('#__options')
					->set($db->qn('option_value') . ' = ' . $db->q($value))
					->where($db->qn('option_name') . ' = ' . $db->q($key));
				$db->setQuery($query)->execute();
			}
			catch (Throwable $e)
			{
				// Swallow it
			}
		}

		try
		{
			$db->disconnect();
		}
		catch (Throwable $exc)
		{
			// No problem, we are done anyway
		}
	}

	/**
	 * Am I restoring to the same URL I backed up from?
	 *
	 * @return  bool
	 * @since   10.0
	 */
	public function isSameSiteURL(): bool
	{
		$config = $this->getContainer()->get('mvcFactory')->model('Configuration');

		/**
		 * When we initialised the Configuration model it read the oldurl from the database. However, at that
		 * point (in the "main" view) we had not already restored the database. Therefore, it was either unable to read
		 * anything, or it was reading false data from an existing database. As a resuktm I need to reload that
		 * information and set it to the configuration object.
		 */
		$array = [];
		$config->addOptionsFromDatabase($array);

		if (isset($array['oldurl']))
		{
			$config->set('oldurl', $array['oldurl']);
			$config->saveToSession();
		}

		/**
		 * The `oldurl` (URL I backed up from) and `homeurl` (URL I am restoring to, as possibly modified by the user),
		 * are both stored in the session.
		 */
		return $config->get('oldurl') == $config->get('homeurl');
	}

	/**
	 * Am I restoring to the same filesystem root I backed up from?
	 *
	 * @return  bool
	 * @since   10.0
	 */
	public function isSameFilesystemRoot(): bool
	{
		// Let's get the reference of the previous absolute path
		$mainModel  = $this->getContainer()->get('mvcFactory')->model('Main');
		$extra_info = $mainModel->getExtraInfo();

		if (!isset($extra_info['root']) || empty($extra_info['root']))
		{
			return false;
		}

		$paths    = $this->getContainer()->get('paths');
		$old_path = rtrim($extra_info['root']['current'], '/');
		$new_path = rtrim($paths->get('site'), '/');

		return $old_path == $new_path;
	}

	/**
	 * Escapes a string so that it's a neutral string inside a regular expression.
	 *
	 * @param   string  $str  The string to escape
	 *
	 * @return  string  The escaped string
	 */
	protected function escape_string_for_regex(?string $str): string
	{
		if (empty($str ?? ''))
		{
			return $str;
		}

		//All regex special chars (according to arkani at iol dot pt below):
		// \ ^ . $ | ( ) [ ]
		// * + ? { } , -
		$patterns = [
			'/\//',
			'/\^/',
			'/\./',
			'/\$/',
			'/\|/',
			'/\(/',
			'/\)/',
			'/\[/',
			'/\]/',
			'/\*/',
			'/\+/',
			'/\?/',
			'/\{/',
			'/\}/',
			'/\,/',
			'/\-/',
		];

		$replace = [
			'\/',
			'\^',
			'\.',
			'\$',
			'\|',
			'\(',
			'\)',
			'\[',
			'\]',
			'\*',
			'\+',
			'\?',
			'\{',
			'\}',
			'\,',
			'\-',
		];

		return preg_replace($patterns, $replace, $str);
	}

	/**
	 * Get the database driver connection options
	 *
	 * @return  array
	 * @since   10.0
	 */
	public function getDatabaseConnectionOptions(): array
	{
		$model      = $this->getContainer()->get('mvcFactory')->model('Database');
		$keys       = $model->getDatabaseNames();
		$firstDbKey = array_shift($keys);

		$connectionVars = $model->getDatabaseInfo($firstDbKey);

		$options = [
			'driver'   => $connectionVars->dbtype,
			'database' => $connectionVars->dbname,
			'select'   => 1,
			'host'     => $connectionVars->dbhost,
			'user'     => $connectionVars->dbuser,
			'password' => $connectionVars->dbpass,
			'prefix'   => $connectionVars->prefix,
		];

		return $options;
	}

	/**
	 * Get the map of IDs to blog URLs
	 *
	 * @param   DatabaseDriverInterface  $db  The database connection
	 *
	 * @return  array  The map, or an empty array if this is not a multisite installation.
	 * @since   10.0
	 */
	private function getMultisiteMap(DatabaseDriverInterface $db): ?array
	{
		static $map = null;

		if (is_null($map))
		{
			$config = $this->getContainer()->get('mvcFactory')->model('Configuration');

			// Which site ID should I use?
			$site_id = $config->get('site_id_current_site', 1);

			// Get all the blogs of this site
			$query = $db->getQuery(true)
				->select(
					[
						$db->qn('blog_id'),
						$db->qn('domain'),
						$db->qn('path'),
					]
				)
				->from($db->qn('#__blogs'))
				->where($db->qn('site_id') . ' = ' . $db->q($site_id));

			try
			{
				$map = $db->setQuery($query)->loadAssocList('blog_id');
			}
			catch (Throwable $e)
			{
				$map = [];
			}
		}

		return $map;
	}

	/**
	 * Returns the default replacement values
	 *
	 * @return  array
	 * @since   10.0
	 */
	private function getDefaultReplacements(): array
	{
		$replacements = [];

		$config = $this->getContainer()->get('mvcFactory')->model('Configuration');

		// Main site's URL
		try
		{
			$newReplacements = $this->getDefaultReplacementsForMainSite($config);
			$replacements    = array_merge($replacements, $newReplacements);
		}
		catch (\Throwable $e)
		{
			// It's not the end of the world...
		}

		// Multisite's URLs
		try
		{
			$newReplacements = $this->getDefaultReplacementsForMultisite($config);
			$replacements    = array_merge($replacements, $newReplacements);
		}
		catch (\Exception $e)
		{
			// It's not the end of the world...
		}

		// Database prefix
		$newReplacements = $this->getDefaultReplacementsForDbPrefix($config);
		$replacements    = array_merge($replacements, $newReplacements);

		// Take into account JSON-encoded data
		foreach ($replacements as $from => $to)
		{
			// If we don't do that we end with the string literal "null" which is incorrect.
			if (is_null($to))
			{
				$to = '';
			}

			$jsonFrom = json_encode($from);
			$jsonTo   = json_encode($to);
			$jsonFrom = trim($jsonFrom, '"');
			$jsonTo   = trim($jsonTo, '"');

			if ($jsonFrom != $from)
			{
				$replacements[$jsonFrom] = $jsonTo;
			}
		}

		// All done
		return $replacements;
	}

	/**
	 * Internal method to get the default replacements for the main site URL
	 *
	 * @param   Configuration  $config         The configuration model
	 * @param   bool           $absolutePaths  Include absolute filesystem paths
	 *
	 * @return  array  Any replacements to add
	 * @since   10.0
	 */
	private function getDefaultReplacementsForMainSite(Configuration $config, bool $absolutePaths = true): array
	{
		// TODO I don't get default replacements for the domain name (wordpress.akeeba.dev ==> example.akeeba.dev)

		$paths        = $this->getContainer()->get('paths');
		$replacements = [];

		// Let's get the reference of the previous absolute path
		$mainModel  = $this->getContainer()->get('mvcFactory')->model('Main');
		$extra_info = $mainModel->getExtraInfo();

		if (isset($extra_info['root']) && $extra_info['root'] && $absolutePaths)
		{
			$old_path = rtrim($extra_info['root']['current'], '/');
			$new_path = rtrim($paths->get('site'), '/');

			// Replace only if they are different
			if ($old_path != $new_path)
			{
				$replacements[$old_path] = $new_path;
			}
		}

		// These values are stored inside the session, after the setup step
		$old_url = $config->get('oldurl');
		$new_url = $config->get('homeurl');

		if ($old_url == $new_url)
		{
			return $replacements;
		}
		$oldUri       = $this->getContainer()->get('uri')->instance($old_url);
		$newUri       = $this->getContainer()->get('uri')->instance($new_url);
		$oldDirectory = $oldUri->getPath() ?: '';
		$newDirectory = $newUri->getPath() ?: '';

		// Replace domain site only if the protocol, the port, or the domain are different.
		if (
			($oldUri->getHost() != $newUri->getHost()) || ($oldUri->getPort() != $newUri->getPort())
			|| ($oldUri->getScheme() != $newUri->getScheme())
		)
		{
			// Normally we need to replace both the domain and path, e.g. https://www.example.com => http://localhost/wp

			$old = $oldUri->toString(['scheme', 'host', 'port', 'path']);
			$new = $newUri->toString(['scheme', 'host', 'port', 'path']);

			// However, if the path is the same then we must only replace the domain.
			if ($oldDirectory == $newDirectory)
			{
				$old = $oldUri->toString(['scheme', 'host', 'port']);
				$new = $newUri->toString(['scheme', 'host', 'port']);
			}

			$replacements[$old] = $new;

			// Some Page Builders use URLs without a protocol, and urlencode them, so we have to add those cases, too.
			$replacements[urlencode($old)] = urlencode($new);

			$old_no_protocol = str_replace(['https:', 'http:'], ['', ''], $old);
			$new_no_protocol = str_replace(['https:', 'http:'], ['', ''], $new);

			$replacements[$old_no_protocol] = $new_no_protocol;
		}

		// If the relative path to the site is different, replace it too, but ONLY if the old directory isn't empty.
		if (!empty($oldDirectory) && ($oldDirectory != $newDirectory))
		{
			$replacements[rtrim($oldDirectory, '/') . '/'] = rtrim($newDirectory, '/') . '/';
		}

		/**
		 * Special case: The Inception Restoration
		 *
		 * When you are restoring the site into a subdirectory of itself and two (old and new) subdirectories begin with
		 * the same substring.
		 *
		 * This causes duplication of the new path, after the common prefix.
		 *
		 * Here are some examples.
		 *
		 * Take a backup from http://www.example.com/foobar and restore it to http://www.example.com/foobar/foobar
		 * This causes two replacements to be made
		 * 1. http://www.example.com/foobar => http://www.example.com/foobar/foobar
		 * 2. /foobar => /foobar/foobar
		 * Since they run in sequence, the URL http://www.example.com/foobar becomes after both replacement are run:
		 * http://www.example.com/foobar/foobar/foobar
		 * Solution: replace /foobar/foobar/foobar with /foobar/foobar
		 *
		 * 1. http://www.example.com/foo/bar ==> http://www.example.com/foo/bar/foo
		 * 2. foo/bar => foo/bar/foo
		 * http://www.example.com/foo/bar becomes http://www.example.com/foo/bar/foo/foo
		 * Solution: replace /foo/bar/foo/foo with /foo/bar/foo
		 *
		 * 1. http://xxx/foo => http://xxx/foo/bar
		 * 2. foo => foo/bar
		 * http://www.example.com/foo becomes http://www.example.com/foo/bar/bar
		 * Solution: replace foo/bar/bar with foo/bar
		 */
		$differentDirectory = trim($oldDirectory, '/') != trim($newDirectory, '/');
		$trimmedOldDir      = trim($oldDirectory, '/');
		$trimmedNewDir      = trim($newDirectory, '/');
		$samePrefix         = !empty($trimmedOldDir) && !empty($trimmedNewDir)
		                      && strpos($trimmedNewDir, $trimmedOldDir) === 0;

		if ($differentDirectory && $samePrefix)
		{
			$suffix                          = substr($trimmedNewDir, strlen($trimmedOldDir));
			$wrongReplacement                = '/' . $trimmedNewDir . '/' . trim($suffix, '/') . '/';
			$correctReplacement              = '/' . $trimmedNewDir . '/';
			$replacements[$wrongReplacement] = $correctReplacement;
		}

		return $replacements;
	}

	/**
	 * Internal method to get the default replacements for multisite's URLs
	 *
	 * @param   Configuration  $config  The configuration model
	 *
	 * @return  array  Any replacements to add
	 * @since   10.0
	 */
	private function getDefaultReplacementsForMultisite(Configuration $config): array
	{
		$replacements = [];
		$db           = $this->getDbo();

		if (!$this->isMultisite())
		{
			return $replacements;
		}

		// These values are stored inside the session, after the setup step
		$old_url = $config->get('oldurl');
		$new_url = $config->get('homeurl');

		// If the URL didn't change do nothing
		if ($old_url == $new_url)
		{
			return $replacements;
		}

		// Get the old and new base domain and base path
		$oldUri = $this->getContainer()->get('uri')->instance($old_url);
		$newUri = $this->getContainer()->get('uri')->instance($new_url);

		$newDomain = $newUri->getHost();
		$oldDomain = $oldUri->getHost();

		$newPath = $newUri->getPath();
		$newPath = empty($newPath) ? '/' : $newPath;
		$oldPath = $config->get('path_current_site', $oldUri->getPath());

		$replaceDomains = $newDomain != $oldDomain;
		$replacePaths   = $oldPath != $newPath;

		// Get the multisites information
		$multiSites = $this->getMultisiteMap($db);

		// Get other information
		$mainBlogId    = $config->get('blog_id_current_site', 1);
		$useSubdomains = $config->get('subdomain_install', 0);

		/**
		 * If we use subdomains and we are restoring to a different path OR we are restoring to localhost THEN
		 * we must convert subdomains to subdirectories.
		 */
		$convertSubdomainsToSubdirs = $this->mustConvertSudomainsToSubdirs($config, $replacePaths, $newDomain);

		// Do I have to replace the domain?
		/** @noinspection PhpStatementHasEmptyBodyInspection */
		if ($oldDomain != $newDomain)
		{
			/**
			 * No, we do not have to do that.
			 *
			 * EXAMPLE: From http://test.web to http://mytest.web
			 *
			 * The main site replacements has already mapped http://test.web to http://mytest.web. If we add another map
			 * test.web to mytest.web consider the following link:
			 * http://test.web/foo/bar
			 * After first replacement (from main site): http://mytest.web/foo/bar (CORRECT)
			 * After second replacement (below): http://mymytest.web/foo/bar (INVALID!)
			 *
			 * This was originally added as a way to convert the entries of the #__blogs tables. However, since we now
			 * handle this special table in the separate method self::updateMultisiteTables() we don't need this
			 * replacement and the problems it entails. Hence, it's commented out.
			 *
			 * Please leave it commented out with this explanatory comment above it to prevent any future "clever" ideas
			 * which could possibly reintroduce it and break things again.
			 */
			// $replacements[$oldDomain] = $newUri->getHost();
		}

		// Maybe I have to do... nothing?
		if ($useSubdomains && !$replaceDomains && !$replacePaths)
		{
			return $replacements;
		}

		// Subdirectories installation and the path hasn't changed
		if (!$useSubdomains && !$replacePaths)
		{
			return $replacements;
		}

		// Loop for each multisite
		foreach ($multiSites as $blogId => $info)
		{
			// Skip the first site, it is the same as the main site
			if ($blogId == $mainBlogId)
			{
				continue;
			}

			// Multisites using subdomains?
			if ($useSubdomains && !$convertSubdomainsToSubdirs)
			{
				$blogDomain = $info['domain'];

				// Extract the subdomain
				$subdomain = substr($blogDomain, 0, -strlen($oldDomain));

				// Add a replacement for this domain
				$replacements[$blogDomain] = $subdomain . $newDomain;

				continue;
			}

			// Convert subdomain install to subdirectory install
			if ($convertSubdomainsToSubdirs)
			{
				$blogDomain = $info['domain'];

				/**
				 * No, you don't need this. You need to convert the old subdomain to the new domain PLUS path **AND**
				 * different RewriteRules in .htaccess to magically transform invalid paths to valid paths. Bleh.
				 */
				// Convert old subdomain (blog1.example.com) to new full domain (example.net)
				// $replacements[$blogDomain] = $newUri->getHost();

				// Convert links in post GUID, e.g. //blog1.example.com/ TO //example.net/mydir/blog1/
				$subdomain           = substr($blogDomain, 0, -strlen($oldDomain) - 1);
				$from                = '//' . $blogDomain;
				$to                  = '//' . $newUri->getHost() . $newUri->getPath() . '/' . $subdomain;
				$to                  = rtrim($to, '/');
				$replacements[$from] = $to;

				continue;
			}

			// Multisites using subdirectories. Let's check if I have to extract the old path.
			$path = (strpos($info['path'], $oldPath) === 0) ? substr($info['path'], strlen($oldPath)) : $info['path'];

			// Construct the new path and add it to the list of replacements
			$path      = trim($path, '/');
			$newMSPath = $newPath . '/' . $path;
			$newMSPath = trim($newMSPath, '/');

			/**
			 * Moving from www.example.com to localhost/foobar
			 *
			 * This would cause two replacements:
			 * http://www.example.com   to http://localhost/foobar
			 * /blog1/                  to /foobar/blog1/
			 *
			 * This means that http://www.example.com/blog1/baz.html becomes http://localhost/foobar/foobar/blog1/baz.html
			 * which is wrong. The only solution is to add another replacement:
			 * /foobar/foobar/blog1/ to /foobar/blog1/
			 */
			$wrongPath                = '/' . trim($newPath . '/' . $newMSPath, '/') . '/';
			$correctPath              = '/' . $newMSPath . '/';
			$replacements[$wrongPath] = $correctPath;

			/**
			 * Now add the replacement http://www.example.com to http://localhost/foobar (BECAUSE THE ORDER WILL REVERSE
			 * BELOW!). However, only do that when the domain changes. If it's the same domain then the rules above and
			 * below this chunk will take care of it. HOWEVER! If the domain is the same BUT the old directory is the
			 * root and the new one is not I still have to run this replacement.
			 */
			$trimmedOldPath             = trim($oldPath, '/');
			$trimmedNewPath             = trim($newPath, '/');
			$migrateFromRootToSubfolder = empty($trimmedOldPath) && !empty($trimmedNewPath);

			if ($replaceDomains || $migrateFromRootToSubfolder)
			{
				$oldFullMultisiteURL                = rtrim($old_url, '/') . '/' . trim($info['path'], '/');
				$newFullMultisiteURL                = rtrim($new_url, '/') . '/' . trim($info['path'], '/');
				$replacements[$oldFullMultisiteURL] = $newFullMultisiteURL;
			}

			// Now add the replacement /blog1/ to /foobar/blog1/ (BECAUSE THE ORDER WILL REVERSE BELOW!)
			$replacements[rtrim($info['path'], '/') . '/'] = '/' . $newMSPath . '/';

		}

		// Important! We have to change subdomains BEFORE the main domain. And for this, we need to reverse the
		// replacements table. If you're wondering why: old domain example.com, new domain www.example.net. This
		// makes blog1.example.com => blog1.www.example.net instead of blog1.example.net (note the extra www). Oops!
		$replacements = array_reverse($replacements);

		return $replacements;
	}

	/**
	 * Internal method to get the default replacements for the database prefix
	 *
	 * @param   Configuration  $config  The configuration model
	 *
	 * @return  array  Any replacements to add
	 * @since   10.0
	 */
	private function getDefaultReplacementsForDbPrefix(Configuration $config): array
	{
		$replacements = [];

		// Replace the table prefix if it's different
		$options   = $this->getDatabaseConnectionOptions();
		$newPrefix = $options['prefix'];
		$oldPrefix = $config->get('olddbprefix');

		// Do not try to replace an empty prefix, it would be catastrophic.
		if (empty($oldPrefix))
		{
			return $replacements;
		}

		if ($oldPrefix != $newPrefix)
		{
			$replacements[$oldPrefix] = $newPrefix;

			return $replacements;
		}

		return $replacements;
	}

	/**
	 * Do I have to convert the subdomain installation to a subdirectory installation?
	 *
	 * @param   Configuration  $config        The Configuration model
	 * @param   bool           $replacePaths  Are we replacing paths with new ones?
	 * @param   string         $newDomain     The new domain we are restoring to.
	 *
	 * @return  bool
	 * @since   10.0
	 */
	private function mustConvertSudomainsToSubdirs(Configuration $config, bool $replacePaths, string $newDomain): bool
	{
		$useSubdomains = $config->get('subdomain_install', 0);

		// If we use subdomains and we are restoring to a different path we MUST convert subdomains to subdirectories
		$convertSubdomainsToSubdirs = $replacePaths && $useSubdomains;

		if (!$convertSubdomainsToSubdirs && $useSubdomains && ($newDomain == 'localhost'))
		{
			/**
			 * Special case: localhost
			 *
			 * Localhost DOES NOT support subdomains. Therefore, the subdomain multisite installation MUST be converted
			 * to a subdirectory installation.
			 *
			 * Why is this special case needed? The previous line will only be triggered if we are restoring to a
			 * different path. However, when you are restoring to localhost you ARE restoring to the root of the site,
			 * i.e. the same path as a live multisite subfolder installation of WordPress. This would mean that BRS
			 * would try to restore as a subdomain installation which would fail on localhost.
			 */
			$convertSubdomainsToSubdirs = true;
		}

		return $convertSubdomainsToSubdirs;
	}
}