<?php

declare(strict_types=1);

/**
 * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
 *
 * @author Christoph Wurst <christoph@winzerhof-wurst.at>
 * @author Sascha Wiswedel <sascha.wiswedel@nextcloud.com>
 *
 * @license GNU AGPL version 3 or any later version
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as
 * published by the Free Software Foundation, either version 3 of the
 * License, or (at your option) any later version.
 *
 * 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 Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program. If not, see <http://www.gnu.org/licenses/>.
 *
 */

namespace OCA\Files\Service;

use Closure;
use OC\Files\Filesystem;
use OC\Files\View;
use OCA\Files\Exception\TransferOwnershipException;
use OCP\Encryption\IManager as IEncryptionManager;
use OCP\Files\FileInfo;
use OCP\Files\IHomeStorage;
use OCP\Files\Mount\IMountManager;
use OCP\IUser;
use OCP\Share\IManager as IShareManager;
use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\Console\Output\NullOutput;
use Symfony\Component\Console\Output\OutputInterface;
use function array_merge;
use function basename;
use function count;
use function date;
use function is_dir;
use function rtrim;

class OwnershipTransferService {

	/** @var IEncryptionManager */
	private $encryptionManager;

	/** @var IShareManager */
	private $shareManager;

	/** @var IMountManager */
	private $mountManager;

	public function __construct(IEncryptionManager $manager,
								IShareManager $shareManager,
								IMountManager $mountManager) {
		$this->encryptionManager = $manager;
		$this->shareManager = $shareManager;
		$this->mountManager = $mountManager;
	}

	/**
	 * @param IUser $sourceUser
	 * @param IUser $destinationUser
	 * @param string $path
	 *
	 * @throws TransferOwnershipException
	 */
	public function transfer(IUser $sourceUser,
							 IUser $destinationUser,
							 string $path,
							 ?OutputInterface $output = null): void {
		$output = $output ?? new NullOutput();
		$sourceUid = $sourceUser->getUID();
		$destinationUid = $destinationUser->getUID();
		$sourcePath = rtrim($sourceUid . '/files/' . $path, '/');

		// target user has to be ready
		if (!$this->encryptionManager->isReadyForUser($destinationUid)) {
			throw new TransferOwnershipException("The target user is not ready to accept files. The user has at least to have logged in once.", 2);
		}

		$date = date('Y-m-d H-i-s');
		$finalTarget = "$destinationUid/files/transferred from $sourceUid on $date";

		// setup filesystem
		Filesystem::initMountPoints($sourceUid);
		Filesystem::initMountPoints($destinationUid);

		$view = new View();
		if (!($view->is_dir($sourcePath) || $view->is_file($sourcePath))) {
			throw new TransferOwnershipException("Unknown path provided: $path", 1);
		}

		// analyse source folder
		$this->analyse(
			$sourceUid,
			$destinationUid,
			$sourcePath,
			$view,
			$output
		);

		// collect all the shares
		$shares = $this->collectUsersShares(
			$sourceUid,
			$output
		);

		// transfer the files
		$this->transferFiles(
			$sourceUid,
			$sourcePath,
			$finalTarget,
			$view,
			$output
		);

		// restore the shares
		$this->restoreShares(
			$sourceUid,
			$destinationUid,
			$shares,
			$output
		);
	}

	private function walkFiles(View $view, $path, Closure $callBack) {
		foreach ($view->getDirectoryContent($path) as $fileInfo) {
			if (!$callBack($fileInfo)) {
				return;
			}
			if ($fileInfo->getType() === FileInfo::TYPE_FOLDER) {
				$this->walkFiles($view, $fileInfo->getPath(), $callBack);
			}
		}
	}

	/**
	 * @param OutputInterface $output
	 *
	 * @throws \Exception
	 */
	protected function analyse(string $sourceUid,
							   string $destinationUid,
							   string $sourcePath,
							   View $view,
							   OutputInterface $output): void {
		$output->writeln('Validating quota');
		$size = $view->getFileInfo($sourcePath, false)->getSize(false);
		$freeSpace = $view->free_space($destinationUid . '/files/');
		if ($size > $freeSpace) {
			$output->writeln('<error>Target user does not have enough free space available.</error>');
			throw new \Exception('Execution terminated.');
		}

		$output->writeln("Analysing files of $sourceUid ...");
		$progress = new ProgressBar($output);
		$progress->start();

		$encryptedFiles = [];
		$this->walkFiles($view, $sourcePath,
			function (FileInfo $fileInfo) use ($progress) {
				if ($fileInfo->getType() === FileInfo::TYPE_FOLDER) {
					// only analyze into folders from main storage,
					if (!$fileInfo->getStorage()->instanceOfStorage(IHomeStorage::class)) {
						return false;
					}
					return true;
				}
				$progress->advance();
				if ($fileInfo->isEncrypted()) {
					$encryptedFiles[] = $fileInfo;
				}
				return true;
			});
		$progress->finish();
		$output->writeln('');

		// no file is allowed to be encrypted
		if (!empty($encryptedFiles)) {
			$output->writeln("<error>Some files are encrypted - please decrypt them first.</error>");
			foreach ($encryptedFiles as $encryptedFile) {
				/** @var FileInfo $encryptedFile */
				$output->writeln("  " . $encryptedFile->getPath());
			}
			throw new \Exception('Execution terminated.');
		}
	}

	private function collectUsersShares(string $sourceUid,
										OutputInterface $output): array {
		$output->writeln("Collecting all share information for files and folders of $sourceUid ...");

		$shares = [];
		$progress = new ProgressBar($output);
		foreach ([\OCP\Share::SHARE_TYPE_GROUP, \OCP\Share::SHARE_TYPE_USER, \OCP\Share::SHARE_TYPE_LINK, \OCP\Share::SHARE_TYPE_REMOTE, \OCP\Share::SHARE_TYPE_ROOM] as $shareType) {
			$offset = 0;
			while (true) {
				$sharePage = $this->shareManager->getSharesBy($sourceUid, $shareType, null, true, 50, $offset);
				$progress->advance(count($sharePage));
				if (empty($sharePage)) {
					break;
				}
				$shares = array_merge($shares, $sharePage);
				$offset += 50;
			}
		}

		$progress->finish();
		$output->writeln('');
		return $shares;
	}

	/**
	 * @throws TransferOwnershipException
	 */
	protected function transferFiles(string $sourceUid,
									 string $sourcePath,
									 string $finalTarget,
									 View $view,
									 OutputInterface $output): void {
		$output->writeln("Transferring files to $finalTarget ...");

		// This change will help user to transfer the folder specified using --path option.
		// Else only the content inside folder is transferred which is not correct.
		if ($sourcePath !== "$sourceUid/files") {
			$view->mkdir($finalTarget);
			$finalTarget = $finalTarget . '/' . basename($sourcePath);
		}
		if ($view->rename($sourcePath, $finalTarget) === false) {
			throw new TransferOwnershipException("Could not transfer files.", 1);
		}
		if (!is_dir("$sourceUid/files")) {
			// because the files folder is moved away we need to recreate it
			$view->mkdir("$sourceUid/files");
		}
	}

	private function restoreShares(string $sourceUid,
								   string $destinationUid,
								   array $shares,
								   OutputInterface $output) {
		$output->writeln("Restoring shares ...");
		$progress = new ProgressBar($output, count($shares));

		foreach ($shares as $share) {
			try {
				if ($share->getShareType() === \OCP\Share::SHARE_TYPE_USER &&
					$share->getSharedWith() === $destinationUid) {
					// Unmount the shares before deleting, so we don't try to get the storage later on.
					$shareMountPoint = $this->mountManager->find('/' . $destinationUid . '/files' . $share->getTarget());
					if ($shareMountPoint) {
						$this->mountManager->removeMount($shareMountPoint->getMountPoint());
					}
					$this->shareManager->deleteShare($share);
				} else {
					if ($share->getShareOwner() === $sourceUid) {
						$share->setShareOwner($destinationUid);
					}
					if ($share->getSharedBy() === $sourceUid) {
						$share->setSharedBy($destinationUid);
					}

					$this->shareManager->updateShare($share);
				}
			} catch (\OCP\Files\NotFoundException $e) {
				$output->writeln('<error>Share with id ' . $share->getId() . ' points at deleted file, skipping</error>');
			} catch (\Exception $e) {
				$output->writeln('<error>Could not restore share with id ' . $share->getId() . ':' . $e->getTraceAsString() . '</error>');
			}
			$progress->advance();
		}
		$progress->finish();
		$output->writeln('');
	}

}
