<?php

declare(strict_types=1);

/**
 * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
 * SPDX-License-Identifier: AGPL-3.0-or-later
 */
namespace OCA\Files\BackgroundJob;

use OCA\Files\AppInfo\Application;
use OCA\Files\Service\SettingsService;
use OCP\AppFramework\Services\IAppConfig;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\BackgroundJob\IJobList;
use OCP\BackgroundJob\QueuedJob;
use OCP\Config\IUserConfig;
use OCP\Files\Folder;
use OCP\Files\IFilenameValidator;
use OCP\Files\IRootFolder;
use OCP\Files\ISetupManager;
use OCP\Files\Node;
use OCP\Files\NotFoundException;
use OCP\IUser;
use OCP\IUserManager;
use OCP\IUserSession;
use OCP\Lock\LockedException;
use Psr\Log\LoggerInterface;

class SanitizeFilenames extends QueuedJob {

	private int $offset;
	private int $limit;
	private int $currentIndex;
	private ?string $charReplacement = null;

	public function __construct(
		ITimeFactory $time,
		private IJobList $jobList,
		private IUserSession $session,
		private IUserManager $manager,
		private IAppConfig $appConfig,
		private IUserConfig $userConfig,
		private IRootFolder $rootFolder,
		private ISetupManager $setupManager,
		private IFilenameValidator $filenameValidator,
		private LoggerInterface $logger,
	) {
		parent::__construct($time);
		$this->setAllowParallelRuns(false);
	}

	/**
	 * Makes the background job do its work
	 *
	 * @param array $argument unused argument
	 * @throws \Exception
	 */
	#[\Override]
	public function run($argument) {
		$this->charReplacement = strval($argument['charReplacement']) ?: null;
		if (isset($argument['errorsOnly'])) {
			$this->retryFailedNodes();
			return;
		}

		$this->offset = intval($argument['offset']);
		$this->limit = intval($argument['limit']);
		if ($this->offset === 0) {
			$this->appConfig->setAppValueInt('sanitize_filenames_status', SettingsService::STATUS_WCF_RUNNING);
		}

		$this->currentIndex = 0;
		foreach ($this->manager->getSeenUsers($this->offset) as $user) {
			$this->sanitizeUserFiles($user);
			$this->currentIndex++;
			$this->appConfig->setAppValueInt('sanitize_filenames_index', $this->currentIndex);

			if ($this->currentIndex === $this->limit) {
				break;
			}
		}

		if ($this->currentIndex === $this->limit) {
			$this->offset += $this->limit;
			$this->jobList->add(self::class, ['limit' => $this->limit, 'offset' => $this->offset, 'charReplacement' => $this->charReplacement]);
			return;
		}

		// No index to process anymore, we are done
		$this->appConfig->deleteAppValue('sanitize_filenames_index');

		$hasErrors = !empty($this->userConfig->getValuesByUsers(Application::APP_ID, 'sanitize_filenames_errors'));
		if ($hasErrors) {
			$this->logger->info('Filename sanitization finished with errors. Retrying failed files in next background job run.');
			$this->jobList->add(self::class, ['errorsOnly' => true, 'charReplacement' => $this->charReplacement]);
			return;
		}

		// we are really done!
		$this->appConfig->setAppValueInt('sanitize_filenames_status', SettingsService::STATUS_WCF_DONE);
	}

	/**
	 * Retry to sanitize files that failed in the first run
	 */
	private function retryFailedNodes(): void {
		$this->logger->debug('Retry sanitizing failed filename sanitization.');
		$results = $this->userConfig->getValuesByUsers(Application::APP_ID, 'sanitize_filenames_errors');

		$hasErrors = false;
		foreach ($results as $userId => $errors) {
			$user = $this->manager->get($userId);
			if ($user === null) {
				// user got deleted meanwhile, ignore
				continue;
			}

			$hasErrors = $hasErrors || $this->retryFailedUserNodes($user, $errors);
			$this->userConfig->deleteUserConfig($userId, Application::APP_ID, 'sanitize_filenames_errors');
		}

		if ($hasErrors) {
			$this->appConfig->setAppValueInt('sanitize_filenames_status', SettingsService::STATUS_WCF_ERROR);
			$this->logger->error('Retrying filename sanitization failed permanently.');
		} else {
			$this->appConfig->setAppValueInt('sanitize_filenames_status', SettingsService::STATUS_WCF_DONE);
			$this->logger->info('Retrying filename sanitization succeeded.');
		}
	}

	private function retryFailedUserNodes(IUser $user, array $errors): bool {
		$this->session->setVolatileActiveUser($user);
		$folder = $this->rootFolder->getUserFolder($user->getUID());

		$this->logger->debug("filename sanitization retry: started for user '{$user->getUID()}'");
		$hasErrors = false;
		foreach ($errors as $path) {
			try {
				$node = $folder->get($path);
				$this->sanitizeNode($node);
			} catch (NotFoundException) {
				// file got deleted meanwhile, ignore
			} catch (\Exception $error) {
				$this->logger->error('filename sanitization failed when retried: ' . $path, ['exception' => $error]);
				$hasErrors = true;
			}
		}

		// tear down FS for user to make sure we do not run out of memory due to cached user FS
		$this->setupManager->tearDown();

		return $hasErrors;
	}


	private function sanitizeUserFiles(IUser $user): void {
		// Set an active user so that event listeners can correctly work (e.g. files versions)
		$this->session->setVolatileActiveUser($user);
		$folder = $this->rootFolder->getUserFolder($user->getUID());

		$this->logger->debug("filename sanitization: started for user '{$user->getUID()}'");
		$errors = $this->sanitizeFolder($folder);

		// tear down FS for user to make sure we do not run out of memory due to cached user FS
		$this->setupManager->tearDown();

		if (!empty($errors)) {
			$this->userConfig->setValueArray($user->getUID(), 'files', 'sanitize_filenames_errors', $errors, true);
		}
	}

	/**
	 * Sanitizes the filenames of all nodes in a folder
	 *
	 * @return list<string> list of nodes that could not be sanitized
	 */
	private function sanitizeFolder(Folder $folder): array {
		$errors = [];
		foreach ($folder->getDirectoryListing() as $node) {
			try {
				$this->sanitizeNode($node);
			} catch (LockedException) {
				$this->logger->debug('filename sanitization skipped: ' . $node->getPath() . ' (file is locked)');
				$errors[] = $node->getPath();
			} catch (\Exception $error) {
				$this->logger->warning('filename sanitization failed: ' . $node->getPath(), ['exception' => $error]);
				$errors[] = $node->getPath();
			}

			if ($node instanceof Folder) {
				$errors = array_merge($errors, $this->sanitizeFolder($node));
			}
		}
		return $errors;
	}

	/**
	 * Sanitizes the filename of a single node
	 *
	 * @throws LockedException If the file is locked
	 * @throws \Exception Unknown error
	 */
	private function sanitizeNode(Node $node): void {
		if ($node->isShared() && !$node->isUpdateable()) {
			// we cannot rename files in shares where we do not have permissions - we do it when sanitizing the owner's files
			return;
		}

		try {
			$oldName = $node->getName();
			$newName = $this->filenameValidator->sanitizeFilename($oldName, $this->charReplacement);
			if ($oldName !== $newName) {
				$newName = $node->getParent()->getNonExistingName($newName);
				$path = rtrim(dirname($node->getPath()), '/');

				$node->move("$path/$newName");
			}
		} catch (NotFoundException) {
			// file got deleted meanwhile, ignore
			// or this is shared without permissions to rename it, ignore (owner will rename it)
		}
	}
}
