<?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\Engine;

use Akeeba\BRS\Framework\Container\ContainerAwareInterface;
use Akeeba\BRS\Framework\Container\ContainerAwareTrait;
use Akeeba\BRS\Framework\Database\AbstractDriver;
use Akeeba\BRS\Framework\Database\DatabaseAwareInterface;
use Akeeba\BRS\Framework\Database\DatabaseAwareTrait;
use Akeeba\BRS\Framework\Database\Metadata\Column;
use Akeeba\BRS\Framework\Timer\Timer;
use Akeeba\BRS\Framework\Timer\TimerAwareInterface;
use Akeeba\BRS\Framework\Timer\TimerAwareTrait;
use Akeeba\BRS\Framework\Timer\TimerInterface;
use Akeeba\BRS\Platform\Replacement\Replacement;
use Psr\Container\ContainerInterface;
use Psr\Log\LoggerAwareTrait;
use Psr\Log\LoggerInterface;
use function array_intersect;
use function array_map;
use function strpos;

defined('_AKEEBA') or die();

class ReplacementEngine implements DatabaseAwareInterface, TimerAwareInterface, ContainerAwareInterface
{
	use DatabaseAwareTrait;
	use TimerAwareTrait;
	use LoggerAwareTrait;
	use ContainerAwareTrait;

	private $warnings = [];

	public function __construct(ContainerInterface $container, AbstractDriver $db)
	{
		$this->setContainer($container);
		$this->setDriver($db);
		$this->setLogger($container->get('log'));
	}

	public function reset(): void
	{
		$this->logger->info('Resetting the data replacement engine');

		$session = $this->container->get('session');

		$session->remove('brs.replacements.minExec');
		$session->remove('brs.replacements.maxExec');
		$session->remove('brs.replacements.bias');
		$session->remove('brs.replacements.maxColumnSize');
		$session->remove('brs.replacements.tables');
		$session->remove('brs.replacements.tablesColumns');
		$session->remove('brs.replacements.tablesPKs');
		$session->remove('brs.replacements.tablesAIs');
		$session->remove('brs.replacements.batchSize');
		$session->remove('brs.replacements.replacements');
		$session->remove('brs.replacements.currentTable');
		$session->remove('brs.replacements.currentOffset');
	}

	public function applyTimerSettings(int $minExec = 1, int $maxExec = 5, int $bias = 75)
	{
		$this->logger->info(sprintf("Timer settings: %d – %d, bias %d%%", $minExec, $maxExec, $bias));

		$session = $this->container->get('session');

		$session->set('brs.replacements.minExec', $minExec);
		$session->set('brs.replacements.maxExec', $maxExec);
		$session->set('brs.replacements.bias', $bias);

		$session->saveData();

		$this->timer = null;
	}

	public function getTimer(): TimerInterface
	{
		if (!$this->timer instanceof TimerInterface)
		{
			$session = $this->container->get('session');

			$minExec = $session->get('brs.replacements.minExec', 1);
			$maxExec = $session->get('brs.replacements.maxExec', 5);
			$bias    = $session->get('brs.replacements.bias', 75);

			$this->timer = new Timer($this->getContainer(), $minExec, $maxExec, $bias);
		}

		return $this->timer;
	}

	public function getLogger(): LoggerInterface
	{
		return $this->logger;
	}

	public function initialize(
		array $includedTables, array $excludedTables, array $excludedFields,
		array $replacements, int $batchSize = 100, int $maxColumnSize = 1048576
	): void
	{
		$this->logger->info('Initializing the data replacement engine');

		// Normalize the names of included and excluded tables
		$excludedTables = array_map(
			function ($tableName) {
				return strpos($tableName, '#__') === 0 ? $this->db->replacePrefix($tableName) : $tableName;
			},
			$excludedTables
		);
		$includedTables = array_map(
			function ($tableName) {
				return strpos($tableName, '#__') === 0 ? $this->db->replacePrefix($tableName) : $tableName;
			},
			$includedTables
		);

		// Find the tables we will apply replacements on
		$allTables = array_intersect($this->db->getTableList(), $includedTables);
		$allTables = array_diff($allTables, $excludedTables);

		// Get the array column metadata
		$allTableColumns = array_combine(
			$allTables,
			array_map(
				function ($tableName) {
					try
					{
						return $this->db->getColumnsMeta($tableName);
					}
					catch (\Exception $e)
					{
						// This is probably a leftover view
						return [];
					}
				},
				$allTables
			)
		);

		$allTableColumns = array_filter($allTableColumns);

		// Get the array to replaceable columns map (e.g. [ ['foo' => ['bar', 'baz']], ... ])
		$tablesWithReplaceableColumns = array_filter(
			array_map(
				function ($columns) {
					$columns = array_filter(
						$columns,
						function (Column $column) {
							return $column->isText();
						}
					);

					if (empty($columns))
					{
						return null;
					}

					return array_map(
						function (Column $column) {
							return $column->getColumnName();
						},
						$columns
					);
				},
				$allTableColumns
			)
		);

		// Apply excluded fields
		foreach ($excludedFields as $tableName => $fieldsList)
		{
			if (!isset($tablesWithReplaceableColumns[$tableName]))
			{
				continue;
			}

			$tablesWithReplaceableColumns[$tableName] = array_diff(
				$tablesWithReplaceableColumns[$tableName], $fieldsList
			);
		}

		// Remove tables with no replaceable columns
		$tablesWithReplaceableColumns = array_filter($tablesWithReplaceableColumns);

		// Get the names of the tables with replaceable columns
		$applicableTables   = array_keys($tablesWithReplaceableColumns);
		$tablePKs           = [];
		$tableAutoIncrement = [];

		foreach ($applicableTables as $tableName)
		{
			$tablePKs[$tableName]           = $this->findPrimaryKey($allTableColumns[$tableName]);
			$tableAutoIncrement[$tableName] = $this->findAutoIncrementColumn($allTableColumns[$tableName]);
		}

		// Save to session
		$session = $this->container->get('session');
		$session->set('brs.replacements.tables', array_keys($tablesWithReplaceableColumns));
		$session->set('brs.replacements.tablesColumns', $tablesWithReplaceableColumns);
		$session->set('brs.replacements.tablesPKs', $tablePKs);
		$session->set('brs.replacements.tablesAIs', $tableAutoIncrement);
		$session->set('brs.replacements.batchSize', $batchSize);
		$session->set('brs.replacements.replacements', $replacements);
		$session->set('brs.replacements.maxColumnSize', $maxColumnSize);

		$session->saveData();

		$this->logger->info(
			sprintf("Found %d tables with replaceable columns", count($tablesWithReplaceableColumns))
		);
	}

	public function step(array $rowFilters = []): bool
	{
		$this->logger->info('Stepping the data replacement engine');

		$session       = $this->container->get('session');
		$currentTable  = $session->get('brs.replacements.currentTable', null);
		$currentOffset = (int) $session->get('brs.replacements.currentOffset', 0);

		if (empty($currentTable))
		{
			$this->logger->debug('Looking for next table');

			$allTables = (array) $session->get('brs.replacements.tables', []);

			if (empty($allTables))
			{
				$this->logger->info('Ran out of tables to process');

				// We are done.
				return false;
			}

			$currentTable  = array_shift($allTables);
			$currentOffset = 0;

			$session->set('brs.replacements.tables', $allTables);
			$session->set('brs.replacements.currentTable', $currentTable);
			$session->set('brs.replacements.currentOffset', $currentOffset);

			$session->saveData();
		}

		$this->logger->info('Processing table ' . $currentTable . ' (offset ' . $currentOffset . ')');

		// Get the names of key columns
		$tableAIs       = (array) $session->get('brs.replacements.tablesAIs', []);
		$tablePKs       = (array) $session->get('brs.replacements.tablesPKs', []);
		$tableColumns   = (array) $session->get('brs.replacements.tablesColumns', []);
		$replacements   = (array) $session->get('brs.replacements.replacements', []);
		$maxColumnSize  = $session->get('brs.replacements.maxColumnSize', 1048576);

		$autoIncColumn  = $tableAIs[$currentTable];
		$keyColumns     = (array) $tablePKs[$currentTable];
		$replaceColumns = (array) $tableColumns[$currentTable];
		$selectColumns  = array_unique(
			array_merge($keyColumns, $replaceColumns, $autoIncColumn ? [$autoIncColumn] : [])
		);

		// Get some rows
		$query = $this->db->getQuery(true)
			->select(array_map([$this->db, 'quoteName'], $selectColumns))
			->from($this->db->quoteName($currentTable))
			->setLimit(
				$session->get('brs.replacements.batchSize', 100),
				$currentOffset
			);

		if ($autoIncColumn)
		{
			$query->order($this->db->quoteName($autoIncColumn) . ' ASC');

		}

		$this->db->setQuery($query);

		$rows = $this->db->loadAssocList();

		if (empty($rows))
		{
			$this->logger->debug(
				sprintf('No more rows to process in %s', $currentTable)
			);

			// No more data on the table.
			$session->set('brs.replacements.currentTable', '');
			$session->set('brs.replacements.currentOffset', 0);

			$session->saveData();

			// Indicate we have more work to do.
			return true;
		}

		$this->logger->debug(
			sprintf('Processing the next %d row(s) in %s', count($rows), $currentTable)
		);

		// Start transaction
		$this->db->transactionStart();

		// Iterate each row
		foreach ($rows as $row)
		{
			// Am I out of time?
			if ($this->getTimer()->getTimeLeft() < 0.01)
			{
				$this->logger->debug('Out of time. Will continue in the next step.');
				break;
			}

			// Indicate I processed another row
			$currentOffset++;

			// Should I try data replacement? (Applies row filters)
			$doProcess = array_reduce(
				$rowFilters,
				function (bool $carry, $filter) use ($row, $currentTable) {
					return $carry && $filter($currentTable, $row);
				},
				true
			);

			if (!$doProcess)
			{
				//$this->logger->debug('Row does not pass the filters. Skipping.');

				continue;
			}

			// Try to replace data in the row
			$newRow = [];

			foreach ($replaceColumns as $columnName)
			{
				$original = $row[$columnName];
				$replaced = $original;

				foreach ($replacements as $from => $to)
				{
					$replaced = Replacement::replace($replaced, $from, $to, false, $maxColumnSize);
				}

				if ($replaced !== $original)
				{
					$newRow[$columnName] = $replaced;
				}
			}

			// Has anything changed?
			if (empty($newRow))
			{
				//$this->logger->debug('No changes to the row. Skipping.');

				continue;
			}

			$doQuery = $this->db->getQuery(true)
				->update($this->db->quoteName($currentTable));

			foreach ($newRow as $columnName => $value)
			{
				if ($value === null)
				{
					$doQuery->set($this->db->quoteName($columnName) . ' = NULL');
				}
				else
				{
					$doQuery->set($this->db->quoteName($columnName) . ' = ' . $this->db->quote($value));
				}
			}

			// Get the where clause for the replacements
			if ($autoIncColumn)
			{
				$doQuery->where($this->db->quoteName($autoIncColumn) . ' = ' . (int) $row[$autoIncColumn]);
			}
			else
			{
				foreach ($keyColumns as $columnName)
				{
					if ($row[$columnName] === null)
					{
						$doQuery->where($this->db->quoteName($columnName) . ' IS NULL');
					}
					else
					{
						$doQuery->where($this->db->quoteName($columnName) . ' = ' . $this->db->quote($row[$columnName]));
					}
				}
			}

			try
			{
				$this->db->setQuery($doQuery)->execute();
			}
			catch (\Exception $e)
			{
				/**
				 * We probably have a duplicate primary key, see ticket #41736.
				 *
				 * In this case we can try to delete the record causing a conflict and do an UPDATE IGNORE.
				 *
				 * This works with WordPress' #__options table, but might just refuse to perform replacements in other
				 * cases – none of which we have encountered as of the date this code was written.
				 */
				if (!empty($keyColumns))
				{
					$delQuery = $this->db->getQuery()
						->delete($this->db->quoteName($currentTable));

					foreach ($keyColumns as $columnName)
					{
						if ($row[$columnName] === null)
						{
							$delQuery->where($this->db->quoteName($columnName) . ' IS NULL');
						}
						else
						{
							$delQuery->where($this->db->quoteName($columnName) . ' = ' . $this->db->quote($row[$columnName]));
						}
					}

					$this->db->setQuery($delQuery)->execute();
				}

				// Convert the query to an UPDATE IGNORE
				$insertQuery = clone $doQuery;
				$insertQuery
					->clear('update')
					->update(' IGNORE ' . $this->db->quoteName($currentTable));

				$this->db->setQuery($doQuery)->execute();
			}
		}

		// Save the engine state
		$session->set('brs.replacements.currentTable', $currentTable);
		$session->set('brs.replacements.currentOffset', $currentOffset);

		$session->saveData();

		// Commit transaction
		$this->db->transactionCommit();

		// We have more work to do
		return true;
	}

	public function getWarnings(): array
	{
		return $this->warnings;
	}

	public function resetWarnings(): void
	{
		$this->warnings = [];
	}

	/**
	 * Find the set of columns which constitute a primary key.
	 *
	 * We are returning whatever we find first: a primary key, a unique key, all columns listed
	 *
	 * @param   Column[]  $columns
	 *
	 * @return  string[]
	 */
	protected function findPrimaryKey(array $columns): array
	{
		// First try to find a Primary Key
		$ret = $this->findColumnsByIndex('PRI', $columns);

		if (!empty($ret))
		{
			return $ret;
		}

		// Next, try to find a Unique Key
		$ret = $this->findColumnsByIndex('UNI', $columns);

		if (!empty($ret))
		{
			return $ret;
		}

		// If all else fails use all columns
		$ret = [];

		foreach ($columns as $column)
		{
			$ret[] = $column->getColumnName();
		}

		return $ret;
	}

	/**
	 * Return a list of column names which belong to the named key
	 *
	 * @param   string    $keyName  The key name to search for
	 * @param   Column[]  $columns  The list of columns to search in
	 *
	 * @return  string[]
	 */
	protected function findColumnsByIndex(string $keyName, array $columns): array
	{
		$ret = [];

		foreach ($columns as $column)
		{
			if ($column->getKeyName() == $keyName)
			{
				$ret[] = $column->getColumnName();
			}
		}

		return $ret;
	}

	/**
	 * Find the auto-increment column of the table
	 *
	 * @param   Column[]  $columns
	 *
	 * @return  string
	 */
	protected function findAutoIncrementColumn(array $columns)
	{
		foreach ($columns as $column)
		{
			if ($column->isAutoIncrement())
			{
				return $column->getColumnName();
			}
		}

		return '';
	}
}