<?php
/**
 * Akeeba Engine
 *
 * @package   akeebaengine
 * @copyright Copyright (c)2006-2025 Nicholas K. Dionysopoulos / Akeeba Ltd
 * @license   https://www.gnu.org/licenses/gpl-3.0.html GNU General Public License version 3, or later
 *
 * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public
 * License as published by the Free Software Foundation, version 3.
 *
 * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
 * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License along with this program. If not, see
 * <https://www.gnu.org/licenses/>.
 */

/**
 * @package     Akeeba\Engine\Core\Domain\Finalizer
 * @subpackage
 *
 * @copyright   A copyright
 * @license     A "Slug" license name e.g. GPL2
 */

namespace Akeeba\Engine\Core\Domain\Finalizer;

use Akeeba\Engine\Factory;
use DateTime;
use Exception;

abstract class AbstractQuotaManagement extends AbstractFinalizer
{
	/**
	 * Abstraction for the engine configuration keys used in the concrete quota management class.
	 *
	 * @since 9.3.1
	 * @var   string[]
	 */
	protected $configKeys = [
		'maxAgeEnable' => 'akeeba.quota.maxage.enable',
		'maxAgeDays'   => 'akeeba.quota.maxage.maxdays',
		'maxAgeKeep'   => 'akeeba.quota.maxage.keepday',
		'countEnable'  => 'akeeba.quota.enable_count_quota',
		'countValue'   => 'akeeba.quota.count_quota',
		'sizeEnable'   => 'akeeba.quota.enable_size_quota',
		'sizeValue'    => 'akeeba.quota.size_quota',
	];

	/**
	 * The ID of the latest backup (the one we are running in right now)
	 *
	 * @since 9.3.1
	 * @var   int
	 */
	protected $latestBackupId;

	/**
	 * Human-readable quote type e.g. 'local', 'remote', etc. for the concrete quota management class.
	 *
	 * @since 9.3.1
	 * @var   string
	 */
	protected $quotaType = 'local';

	/**
	 * @inheritDoc
	 */
	public function __invoke()
	{
		$this->setStep(
			sprintf(
				'Applying %s quotas',
				$this->quotaType
			)
		);
		$this->setSubstep('');

		// If no quota settings are enabled, quit
		$configuration  = Factory::getConfiguration();
		$timer          = Factory::getTimer();
		$useDayQuotas   = $configuration->get($this->configKeys['maxAgeEnable']);
		$useCountQuotas = $configuration->get($this->configKeys['countEnable']);
		$useSizeQuotas  = $configuration->get($this->configKeys['sizeEnable']);

		if (!($useDayQuotas || $useCountQuotas || $useSizeQuotas))
		{
			Factory::getLog()->debug(
				sprintf(
					'No %s quotas were defined; old backup files will be kept intact',
					$this->quotaType
				)
			);

			return true;
		}

		// Get the latest backup ID
		$statistics           = Factory::getStatistics();
		$this->latestBackupId = $statistics->getId();

		// Try to load the calculated quotas from the volatile keys
		$keyIsCalculated    = sprintf('volatile.quotas.%s.calculated', $this->quotaType);
		$keyRemoveBackupIDs = sprintf('volatile.quotas.%s.removeBackupIDs', $this->quotaType);
		$keyRemoveLogPaths  = sprintf('volatile.quotas.%s.removeLogPaths', $this->quotaType);
		$keyFilesToRemove   = sprintf('volatile.quotas.%s.filesToRemove', $this->quotaType);

		$isCalculated    = $configuration->get($keyIsCalculated, false);
		$removeBackupIDs = $configuration->get($keyRemoveBackupIDs, []);
		$removeLogPaths  = $configuration->get($keyRemoveLogPaths, []);
		$filesToRemove   = $configuration->get($keyFilesToRemove, []);

		// Calculate the quotas if nothing was calculated just yet.
		if (!$isCalculated)
		{
			// Calculate the quotas. If nothing is found, return immediately.
			if ($this->calculateQuotas($allRecords, $removeBackupIDs, $removeLogPaths, $filesToRemove) === false)
			{
				return true;
			}

			$this->saveCalculatedQuotas($removeBackupIDs, $removeLogPaths, $filesToRemove);

			// Do I have enough time to process removals?
			if ($timer->getTimeLeft() <= 0)
			{
				return false;
			}
		}

		// Process a chunk of removals
		if (!$this->processRemovals($removeBackupIDs, $filesToRemove, $removeLogPaths))
		{
			$this->saveCalculatedQuotas($removeBackupIDs, $removeLogPaths, $filesToRemove);

			return false;
		}

		$removeBackupIDs = null;
		$removeLogPaths  = null;
		$filesToRemove   = null;

		$this->saveCalculatedQuotas($removeBackupIDs, $removeLogPaths, $filesToRemove);

		return true;
	}

	/**
	 * Get all the backup records to apply quotas on.
	 *
	 * @return  array
	 *
	 * @since   9.3.1
	 */
	abstract protected function getAllRecords(): array;

	/**
	 * Processes a list of records for removal. The removal DOES NOT take place here.
	 *
	 * @param   array  $allRecords       The records to process
	 * @param   array  $removeBackupIDs  Running tally of backup IDs to remove files from
	 * @param   array  $removeLogPaths   Running tally of log entries to remove
	 * @param   array  $ret              Running tally of arrays of files to remove
	 * @param   array  $leftover         Leftover records to be processed by the next quota rule
	 *
	 * @since   9.3.1
	 */
	protected function markAllRecordsForRemoval(array &$allRecords, array &$removeBackupIDs, array &$removeLogPaths, array &$ret, array &$leftover)
	{
		foreach ($allRecords as $def)
		{
			if ($def['id'] != $this->latestBackupId)
			{
				continue;
			}

			$temp       = array_pop($leftover);
			$leftover[] = $def;
			array_unshift($allRecords, $temp);

			break;
		}

		foreach ($allRecords as $def)
		{
			$ret[]             = $def['filenames'];
			$removeBackupIDs[] = $def['id'];

			if (empty($def['logname']))
			{
				continue;
			}

			$filePath = $def['absolute_path'];

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

			$logPath = dirname($filePath) . '/' . $def['logname'];

			if (@file_exists($logPath))
			{
				$removeLogPaths[] = $logPath;

				continue;
			}

			$altLogPath = substr($logPath, 0, -4);

			if (@file_exists($altLogPath))
			{
				/**
				 * Bad host: the log file akeeba.tag.log.php may not exist but the akeeba.tag.log file
				 * does. This code addresses this problem.
				 */
				$removeLogPaths[] = $altLogPath;
			}
		}
	}

	/**
	 * Performs the actual removal.
	 *
	 * @param   array  $removeBackupIDs  The backup IDs which will have their files removed
	 * @param   array  $filesToRemove    The flat list of files to remove
	 * @param   array  $removeLogPaths   The flat list of log paths to remove
	 *
	 * @return  bool  True if we are done, false to come back in the next step of the engine
	 * @throws  Exception
	 * @since   9.3.1
	 */
	abstract protected function processRemovals(array &$removeBackupIDs, array &$filesToRemove, array &$removeLogPaths): bool;

	/**
	 * Applies the Count Quotas.
	 *
	 * @param   array  $allRecords       All records left to process
	 * @param   array  $removeBackupIDs  Running tally of backup IDs to remove files from
	 * @param   array  $removeLogPaths   Running tally of log entries to remove
	 * @param   array  $ret              Running tally of arrays of files to remove
	 *
	 * @return  void
	 * @since   9.3.1
	 */
	private function applyCountQuotas(array &$allRecords, array &$removeBackupIDs, array &$removeLogPaths, array &$ret)
	{
		$configuration  = Factory::getConfiguration();
		$useCountQuotas = $configuration->get($this->configKeys['countEnable']);
		$countQuota     = $configuration->get($this->configKeys['countValue']);

		// Do we need to apply count quotas?
		if (!$useCountQuotas || !is_numeric($countQuota) || $countQuota <= 0)
		{
			return;
		}

		// We should only run a count quota if there are more files than the set limit
		if (count($allRecords) <= $countQuota)
		{
			return;
		}

		Factory::getLog()->debug(
			sprintf(
				'Processing %s count quotas',
				$this->quotaType
			)
		);

		/**
		 * Backups are sorted by reverse ID order, e.g. 200, 199, 198, 197, 196, 195, 194, 193, 192, 191, 190, 189, 188.
		 * I need to keep the first $countQuota records in $leftover and process the remaining records.
		 */
		$leftover   = array_slice($allRecords, 0, $countQuota);
		$allRecords = array_slice($allRecords, $countQuota);

		$this->markAllRecordsForRemoval($allRecords, $removeBackupIDs, $removeLogPaths, $ret, $leftover);

		$allRecords = $leftover;
	}

	/**
	 * Applies the Day-Based Quotas.
	 *
	 * @param   array  $allRecords       All records left to process
	 * @param   array  $removeBackupIDs  Running tally of backup IDs to remove files from
	 * @param   array  $removeLogPaths   Running tally of log entries to remove
	 * @param   array  $ret              Running tally of arrays of files to remove
	 *
	 * @return  void
	 * @since   9.3.1
	 */
	private function applyDayQuotas(array &$allRecords, array &$removeBackupIDs, array &$removeLogPaths, array &$ret): void
	{
		$configuration = Factory::getConfiguration();
		$daysQuota     = $configuration->get($this->configKeys['maxAgeDays']);
		$preserveDay   = $configuration->get($this->configKeys['maxAgeKeep']);
		$leftover      = [];

		$killDatetime = new DateTime();
		$killDatetime->modify('-' . $daysQuota . ($daysQuota == 1 ? ' day' : ' days'));
		$killTS = $killDatetime->format('U');

		/**
		 * Move the following kind of records FROM allRecords TO leftover:
		 * - Current backup record
		 * - Backups on a preserve day
		 * - Backups newer than the earliest removal date
		 */
		$allRecords = array_filter(
			$allRecords,
			function (array $def) use ($killDatetime, $killTS, $preserveDay, &$leftover): bool {
				if ($def['id'] == $this->latestBackupId)
				{
					$leftover[] = $def;

					return false;
				}

				// Is this on a preserve day?
				if ($preserveDay > 0 && $def['day'] == $preserveDay)
				{
					$leftover[] = $def;

					return false;
				}

				// Otherwise, check the timestamp
				if ($def['backupstart'] >= $killTS)
				{
					$leftover[] = $def;

					return false;
				}

				return true;
			}
		);

		$this->markAllRecordsForRemoval($allRecords, $removeBackupIDs, $removeLogPaths, $ret, $leftover);

		$allRecords = $leftover;
	}

	/**
	 * Applies the Maximum Size Quotas.
	 *
	 * @param   array  $allRecords       All records left to process
	 * @param   array  $removeBackupIDs  Running tally of backup IDs to remove files from
	 * @param   array  $removeLogPaths   Running tally of log entries to remove
	 * @param   array  $ret              Running tally of arrays of files to remove
	 *
	 * @return  void
	 * @since   9.3.1
	 */
	private function applySizeQuotas(array &$allRecords, array &$removeBackupIDs, array &$removeLogPaths, array &$ret)
	{
		$configuration = Factory::getConfiguration();
		$useSizeQuotas = $configuration->get($this->configKeys['sizeEnable']);
		$sizeQuota     = $configuration->get($this->configKeys['sizeValue']);

		// Do we need to apply size quotas?
		if (!$useSizeQuotas || !is_numeric($sizeQuota) || $sizeQuota <= 0 || count($allRecords) <= 0)
		{
			return;
		}

		Factory::getLog()->debug(
			sprintf(
				'Processing %s size quotas',
				$this->quotaType
			)
		);

		// First I will find how many elements of the array I need to get to the $sizeQuota size.
		$runningSize     = 0;
		$numberOfRecords = 0;

		foreach ($allRecords as $def)
		{
			$numberOfRecords++;

			if ($def['id'] == $this->latestBackupId)
			{
				continue;
			}

			$runningSize += $def['size'];

			if ($runningSize >= $sizeQuota)
			{
				break;
			}
		}

		$leftover   = array_slice($allRecords, 0, $numberOfRecords);
		$allRecords = array_slice($allRecords, $numberOfRecords);

		$this->markAllRecordsForRemoval($allRecords, $removeBackupIDs, $removeLogPaths, $ret, $leftover);

		$allRecords = $leftover;
	}

	private function calculateQuotas(&$allRecords, &$removeBackupIDs, &$removeLogPaths, &$filesToRemove): bool
	{
		$configuration = Factory::getConfiguration();
		$useDayQuotas  = $configuration->get($this->configKeys['maxAgeEnable']);

		$allRecords = $this->getAllRecords();

		// If there are no files, exit early
		if (count($allRecords) == 0)
		{
			Factory::getLog()->debug(
				sprintf(
					'There were no old backup records to apply %s quotas on',
					$this->quotaType
				)
			);

			return false;
		}

		// Init arrays
		$removeBackupIDs = [];
		$removeLogPaths  = [];
		$ret             = [];

		// Do we need to apply maximum backup age quotas?
		if ($useDayQuotas)
		{
			$this->applyDayQuotas($allRecords, $removeBackupIDs, $removeLogPaths, $ret);
		}
		else
		{
			$this->applyCountQuotas($allRecords, $removeBackupIDs, $removeLogPaths, $ret);
			$this->applySizeQuotas($allRecords, $removeBackupIDs, $removeLogPaths, $ret);
		}

		// Convert the $ret 2-dimensional array to single dimensional
		$filesToRemove = [];

		foreach ($ret as $temp)
		{
			$filesToRemove = array_merge($filesToRemove ?? [], $temp);
		}

		return true;
	}

	/**
	 * Save the calculated quotas into the volatile storage
	 *
	 * @param   array|null  $removeBackupIDs  Running tally of backup IDs to remove files from
	 * @param   array|null  $removeLogPaths   Running tally of log entries to remove
	 * @param   array|null  $filesToRemove    The flat list of files to remove
	 *
	 * @return  void
	 * @since   9.3.1
	 */
	private function saveCalculatedQuotas(?array &$removeBackupIDs, ?array &$removeLogPaths, ?array &$filesToRemove): void
	{
		$configuration      = Factory::getConfiguration();
		$keyIsCalculated    = sprintf('volatile.quotas.%s.calculated', $this->quotaType);
		$keyRemoveBackupIDs = sprintf('volatile.quotas.%s.removeBackupIDs', $this->quotaType);
		$keyRemoveLogPaths  = sprintf('volatile.quotas.%s.removeLogPaths', $this->quotaType);
		$keyFilesToRemove   = sprintf('volatile.quotas.%s.filesToRemove', $this->quotaType);

		$isCalculated = $removeBackupIDs !== null || $removeLogPaths !== null || $filesToRemove !== null;

		if ($isCalculated)
		{
			$configuration->set($keyIsCalculated, true);
			$configuration->set($keyRemoveBackupIDs, $removeBackupIDs);
			$configuration->set($keyRemoveLogPaths, $removeLogPaths);
			$configuration->set($keyFilesToRemove, $filesToRemove);

			return;
		}

		$configuration->remove($keyIsCalculated);
		$configuration->remove($keyRemoveBackupIDs);
		$configuration->remove($keyRemoveLogPaths);
		$configuration->remove($keyFilesToRemove);
	}
}