<?php

declare(strict_types=1);

/**
 * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
 * SPDX-License-Identifier: AGPL-3.0-or-later
 */

namespace OCA\Text\Service;

use InvalidArgumentException;
use OCA\Text\AppInfo\Application;
use OCA\Text\Db\Document;
use OCA\Text\Db\DocumentMapper;
use OCA\Text\Db\Session;
use OCA\Text\Db\SessionMapper;
use OCA\Text\Db\Step;
use OCA\Text\Db\StepMapper;
use OCA\Text\Exception\DocumentHasUnsavedChangesException;
use OCA\Text\Exception\DocumentSaveConflictException;
use OCA\Text\YjsMessage;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\Constants;
use OCP\DB\Exception;
use OCP\DirectEditing\IManager;
use OCP\Files\Config\IUserMountCache;
use OCP\Files\File;
use OCP\Files\Folder;
use OCP\Files\IAppData;
use OCP\Files\InvalidPathException;
use OCP\Files\IRootFolder;
use OCP\Files\Lock\ILock;
use OCP\Files\Lock\ILockManager;
use OCP\Files\Lock\LockContext;
use OCP\Files\Lock\NoLockProviderException;
use OCP\Files\Lock\OwnerLockedException;
use OCP\Files\Node;
use OCP\Files\NotFoundException;
use OCP\Files\NotPermittedException;
use OCP\Files\SimpleFS\ISimpleFile;
use OCP\ICache;
use OCP\ICacheFactory;
use OCP\IConfig;
use OCP\IRequest;
use OCP\Lock\LockedException;
use OCP\PreConditionNotMetException;
use OCP\Share\Exceptions\ShareNotFound;
use OCP\Share\IManager as ShareManager;
use Psr\Log\LoggerInterface;
use function json_encode;

class DocumentService {

	/**
	 * Delay to wait for between autosave versions
	 */
	public const AUTOSAVE_MINIMUM_DELAY = 10;

	private bool $saveFromText = false;

	private ?string $userId;
	private DocumentMapper $documentMapper;
	private SessionMapper $sessionMapper;
	private LoggerInterface $logger;
	private ShareManager $shareManager;
	private StepMapper $stepMapper;
	private IRootFolder $rootFolder;
	private ICache $cache;
	private IAppData $appData;
	private ILockManager $lockManager;
	private IUserMountCache $userMountCache;
	private IConfig $config;

	public function __construct(DocumentMapper $documentMapper, StepMapper $stepMapper, SessionMapper $sessionMapper, IAppData $appData, ?string $userId, IRootFolder $rootFolder, ICacheFactory $cacheFactory, LoggerInterface $logger, ShareManager $shareManager, IRequest $request, IManager $directManager, ILockManager $lockManager, IUserMountCache $userMountCache, IConfig $config) {
		$this->documentMapper = $documentMapper;
		$this->stepMapper = $stepMapper;
		$this->sessionMapper = $sessionMapper;
		$this->userId = $userId;
		$this->appData = $appData;
		$this->rootFolder = $rootFolder;
		$this->cache = $cacheFactory->createDistributed('text');
		$this->logger = $logger;
		$this->shareManager = $shareManager;
		$this->lockManager = $lockManager;
		$this->userMountCache = $userMountCache;
		$this->config = $config;
		$token = $request->getParam('token');
		if ($this->userId === null && $token !== null) {
			try {
				$tokenObject = $directManager->getToken($token);
				$tokenObject->extend();
				$tokenObject->useTokenScope();
				$this->userId = $tokenObject->getUser();
			} catch (\Exception $e) {
			}
		}
	}

	public function getDocument(int $id): ?Document {
		try {
			return $this->documentMapper->find($id);
		} catch (DoesNotExistException|NotFoundException $e) {
			return null;
		}
	}

	public function isSaveFromText(): bool {
		return $this->saveFromText;
	}

	/**
	 * @throws NotFoundException
	 * @throws InvalidPathException
	 * @throws NotPermittedException
	 * @throws Exception
	 */
	public function getOrCreateDocument(File $file): Document {
		$document = $this->getDocument($file->getId());
		if ($document !== null) {
			$this->logger->info('Keep previous document of ' . $file->getId());
			return $document;
		}

		if (!$this->ensureDocumentsFolder()) {
			throw new NotFoundException('No app data folder present for text documents');
		}

		$this->logger->info('Create new document of ' . $file->getId());
		$document = new Document();
		$document->setId($file->getId());
		$document->setLastSavedVersion(0);
		$document->setLastSavedVersionTime($file->getMTime());
		$document->setLastSavedVersionEtag($file->getEtag());
		$document->setBaseVersionEtag(uniqid());
		$document->setChecksum($this->computeCheckSum($file->getContent()));
		try {
			/** @var Document $document */
			$document = $this->documentMapper->insert($document);
			$this->cache->set('document-version-' . $document->getId(), 0);
		} catch (Exception $e) {
			if ($e->getReason() !== Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) {
				throw $e;
			}
			// Document might have been created in the meantime
			$document = $this->getDocument($file->getId());
			if ($document === null) {
				throw $e;
			}
		}
		return $document;
	}

	/**
	 * @param int $documentId
	 * @return ISimpleFile
	 * @throws NotFoundException
	 */
	public function getStateFile(int $documentId): ISimpleFile {
		$filename = $documentId . '.yjs';
		if (!$this->ensureDocumentsFolder()) {
			throw new NotFoundException('No app data folder present for text documents');
		}
		return $this->appData->getFolder('documents')->getFile($filename);
	}

	/**
	 * @param int $documentId
	 *
	 * @return ISimpleFile
	 * @throws NotPermittedException
	 */
	public function createStateFile(int $documentId): ISimpleFile {
		$filename = $documentId . '.yjs';
		return $this->appData->getFolder('documents')->newFile($filename);
	}

	/**
	 * @param int $documentId
	 * @param string $content
	 */
	public function writeDocumentState(int $documentId, string $content): void {
		try {
			$documentStateFile = $this->getStateFile($documentId);
		} catch (NotFoundException $e) {
			$documentStateFile = $this->createStateFile($documentId);
		} catch (NotPermittedException $e) {
			$this->logger->error('Failed to create document state file', ['exception' => $e]);
			return;
		}
		$documentStateFile->putContent($content);
	}

	/**
	 * @throws InvalidArgumentException
	 * @throws NotFoundException
	 * @throws NotPermittedException
	 * @throws DoesNotExistException
	 */
	public function addStep(Document $document, Session $session, array $steps, int $version, ?int $recoveryAttempt, ?string $shareToken): array {
		$documentId = $session->getDocumentId();
		$readOnly = $this->isReadOnlyCached($session, $shareToken);
		$stepsToInsert = [];
		$stepsIncludeQuery = false;
		$documentState = null;
		foreach ($steps as $step) {
			$message = YjsMessage::fromBase64($step);
			if ($readOnly && $message->isUpdate()) {
				continue;
			}
			// Filter out query steps as they would just trigger clients to send their steps again
			if ($message->getYjsMessageType() === YjsMessage::YJS_MESSAGE_SYNC && $message->getYjsSyncType() === YjsMessage::YJS_MESSAGE_SYNC_STEP1) {
				$stepsIncludeQuery = true;
			} else {
				$stepsToInsert[] = $step;
			}
		}
		if (count($stepsToInsert) > 0) {
			if ($readOnly) {
				throw new NotPermittedException('Read-only client tries to push steps with changes');
			}
			$this->insertSteps($document, $session, $stepsToInsert);
		}

		// By default, send all steps the user has not received yet.
		$getStepsSinceVersion = $version;
		if ($stepsIncludeQuery) {
			if ($recoveryAttempt === 1) {
				$this->logger->error('Recovery attempt #' . $recoveryAttempt . ' from ' . $session->getId() . ' for ' . $documentId);
			} elseif ($recoveryAttempt > 1) {
				$this->logger->debug('Recovery attempt #' . $recoveryAttempt . ' from ' . $session->getId() . ' for ' . $documentId);
			}
			$this->logger->debug('Loading document state for ' . $documentId);
			try {
				$stateFile = $this->getStateFile($documentId);
				$documentState = $stateFile->getContent();
				$this->logger->debug('Existing document, state file loaded ' . $documentId);
				// If there were any queries in the steps, send all steps starting 200 steps before last save.
				// Adding 200 previous steps to workaround race conditions where state with missing step got persisted in the document state. See #7692
				$getStepsSinceVersion = $this->stepMapper->getBeforeVersion($documentId, $document->getLastSavedVersion(), 200);
			} catch (NotFoundException $e) {
				$this->logger->debug('Existing document, but no state file found for ' . $documentId);
				// If there is no state file, include all the steps.
				$getStepsSinceVersion = 0;
			}
		}

		$allSteps = $this->getSteps($documentId, $getStepsSinceVersion);
		$stepsToReturn = [];
		foreach ($allSteps as $step) {
			$message = YjsMessage::fromBase64($step->getData());
			if ($message->getYjsMessageType() === YjsMessage::YJS_MESSAGE_SYNC && $message->getYjsSyncType() === YjsMessage::YJS_MESSAGE_SYNC_UPDATE) {
				$stepsToReturn[] = $step;
			}
		}

		return [
			'steps' => $stepsToReturn,
			'version' => isset($documentState) ? $document->getLastSavedVersion() : 0,
			'documentState' => $documentState
		];
	}

	/**
	 * @param Document $document
	 * @param Session $session
	 * @param Step[] $steps
	 *
	 * @throws DoesNotExistException
	 * @throws InvalidArgumentException
	 *
	 * @psalm-param non-empty-list<mixed> $steps
	 */
	private function insertSteps(Document $document, Session $session, array $steps): void {
		$stepsVersion = null;
		try {
			$stepsJson = json_encode($steps, JSON_THROW_ON_ERROR);
			$stepsVersion = $this->stepMapper->getLatestVersion($document->getId());
			$step = new Step();
			$step->setData($stepsJson);
			$step->setSessionId($session->getId());
			$step->setDocumentId($document->getId());
			$step->setVersion(Step::VERSION_STORED_IN_ID);
			$step->setTimestamp(time());
			$step = $this->stepMapper->insert($step);
			$newVersion = $step->getId();
			$this->logger->debug('Adding steps to ' . $document->getId() . ": bumping version from $stepsVersion to $newVersion");
			$this->cache->set('document-version-' . $document->getId(), $newVersion);
			// TODO write steps to cache for quicker reading
		} catch (\Throwable $e) {
			if ($stepsVersion !== null) {
				$this->logger->error('This should never happen. An error occurred when storing the version, trying to recover the last stable one', ['exception' => $e]);
				$this->cache->set('document-version-' . $document->getId(), $stepsVersion);
				$this->stepMapper->deleteAfterVersion($document->getId(), $stepsVersion);
			}
			throw $e;
		}
	}

	/** @return Step[] */
	public function getSteps(int $documentId, int $lastVersion): array {
		if ($lastVersion === $this->cache->get('document-version-' . $documentId)) {
			return [];
		}
		return $this->stepMapper->find($documentId, $lastVersion);
	}



	/**
	 * @throws DocumentSaveConflictException
	 * @throws InvalidPathException
	 * @throws NotFoundException
	 */
	public function assertNoOutsideConflict(Document $document, File $file, bool $force = false, ?string $shareToken = null): void {
		$documentId = $document->getId();
		$lastMTime = $document->getLastSavedVersionTime();
		$lastEtag = $document->getLastSavedVersionEtag();

		if ($lastMTime <= 0 || $force || $this->isReadOnly($file, $shareToken) || $this->cache->get('document-save-lock-' . $documentId)) {
			return;
		}

		$fileMtime = $file->getMtime();
		$fileEtag = $file->getEtag();

		if ($lastEtag === $fileEtag && $lastMTime === $fileMtime) {
			return;
		}

		$storedChecksum = $document->getChecksum();
		$fileContent = $file->getContent();
		$fileChecksum = $this->computeChecksum($fileContent);

		if ($storedChecksum !== $fileChecksum) {
			throw new DocumentSaveConflictException('File changed in the meantime from outside');
		}

		$document->setLastSavedVersionTime($fileMtime);
		$document->setLastSavedVersionEtag($fileEtag);
		$this->documentMapper->update($document);
	}

	/**
	 * @param string $content
	 * @return string
	 */
	private function computeCheckSum(string $content): string {
		return hash('crc32', $content);
	}

	/**
	 * @throws DocumentSaveConflictException
	 * @throws DoesNotExistException
	 * @throws InvalidPathException
	 * @throws NotFoundException
	 * @throws NotPermittedException
	 * @throws Exception
	 */
	public function autosave(Document $document, File $file, int $version, string $autoSaveDocument, string $documentState, bool $force = false, bool $manualSave = false, ?string $shareToken = null): Document {
		$documentId = $document->getId();

		if ($this->isReadOnly($file, $shareToken)) {
			return $document;
		}

		$this->assertNoOutsideConflict($document, $file, $force);

		// Do not save if newer version already saved
		// Note that $version is the version of the steps the client has fetched.
		// It may have added steps on top of that - so if the versions match we still save.
		$stepsVersion = $this->stepMapper->getLatestVersion($documentId) ?? 0;
		$savedVersion = $document->getLastSavedVersion();
		$outdated = $savedVersion > 0 && $savedVersion > $version;
		if (!$force && ($outdated || $version > (string)$stepsVersion)) {
			return $document;
		}

		// Only save once every AUTOSAVE_MINIMUM_DELAY seconds
		$lastMTime = $document->getLastSavedVersionTime();
		if ($file->getMTime() === $lastMTime && $lastMTime > time() - self::AUTOSAVE_MINIMUM_DELAY && $manualSave === false) {
			return $document;
		}

		if (empty($autoSaveDocument)) {
			$this->logger->warning('Saving empty document', [
				'requestVersion' => $version,
				'requestAutosaveDocument' => $autoSaveDocument,
				'requestDocumentState' => $documentState,
				'document' => $document->jsonSerialize(),
				'fileSizeBeforeSave' => $file->getSize(),
				'steps' => array_map(static function (Step $step) {
					return $step->jsonSerialize();
				}, $this->stepMapper->find($documentId, 0)),
				'sessions' => array_map(static function (Session $session) {
					return $session->jsonSerialize();
				}, $this->sessionMapper->findAll($documentId))
			]);
		}

		// Version changed but the content remains the same
		if ($autoSaveDocument === $file->getContent()) {
			$this->writeDocumentState($file->getId(), $documentState);
			$document->setLastSavedVersion($version);
			$document->setLastSavedVersionTime($file->getMTime());
			$document->setLastSavedVersionEtag($file->getEtag());
			$this->documentMapper->update($document);
			return $document;
		}

		$this->cache->set('document-save-lock-' . $documentId, true, 10);
		try {
			$this->lockManager->runInScope(new LockContext(
				$file,
				ILock::TYPE_APP,
				Application::APP_NAME
			), function () use ($file, $autoSaveDocument, $documentState) {
				$this->saveFromText = true;
				$file->putContent($autoSaveDocument);
				$this->writeDocumentState($file->getId(), $documentState);
			});
			$document->setLastSavedVersion($version);
			$document->setLastSavedVersionTime($file->getMTime());
			$document->setLastSavedVersionEtag($file->getEtag());
			$document->setChecksum($this->computeCheckSum($autoSaveDocument));
			$this->documentMapper->update($document);
		} catch (LockedException $e) {
			// Ignore lock since it might occur when multiple people save at the same time
			return $document;
		} finally {
			$this->cache->remove('document-save-lock-' . $documentId);
		}
		return $document;
	}

	/**
	 * @throws DocumentHasUnsavedChangesException
	 * @throws Exception
	 * @throws NotPermittedException
	 */
	public function resetDocument(int $documentId, bool $force = false): void {
		try {
			$document = $this->documentMapper->find($documentId);
			if (!$force && $this->hasUnsavedChanges($document)) {
				$this->logger->debug('did not reset document for ' . $documentId);
				throw new DocumentHasUnsavedChangesException('Did not reset document, as it has unsaved changes');
			}

			$this->unlock($documentId);

			$this->stepMapper->deleteAll($documentId);
			$this->sessionMapper->deleteByDocumentId($documentId);
			$this->documentMapper->delete($document);
			$this->getStateFile($documentId)->delete();

			$this->logger->debug('document reset for ' . $documentId);
		} catch (DoesNotExistException|NotFoundException $e) {
			// Ignore if document not found or state file not found
		}
	}

	public function getAll(): \Generator {
		return $this->documentMapper->findAll();
	}

	public function getAllWithNoActiveSession(): \Generator {
		return $this->documentMapper->findAllWithNoActiveSessions();
	}

	/**
	 * @throws NotPermittedException
	 * @throws NotFoundException
	 */
	public function getFileForSession(Session $session, ?string $shareToken = null): File {
		if (!$session->isGuest()) {
			try {
				return $this->getFileById($session->getDocumentId(), $session->getUserId());
			} catch (NotFoundException) {
				// We may still have a user session but on a public share link so move on
			}
		}

		if ($shareToken === null) {
			throw new \InvalidArgumentException('No proper share data');
		}

		try {
			$share = $this->shareManager->getShareByToken($shareToken);
		} catch (ShareNotFound $e) {
			throw new NotFoundException();
		}

		$node = $share->getNode();
		if ($node instanceof Folder) {
			$node = $node->getFirstNodeById($session->getDocumentId());
		}
		if ($node instanceof File) {
			return $node;
		}
		throw new \InvalidArgumentException('No proper share data');
	}

	/**
	 * @throws NotFoundException
	 * @throws NotPermittedException
	 */
	public function getFileById(int $fileId, ?string $userId = null): File {
		$userId = $userId ?? $this->userId;

		// If no user is provided we need to get any file from existing mounts for cleanup jobs
		if ($userId === null) {
			$mounts = $this->userMountCache->getMountsForFileId($fileId);
			$anyMount = array_shift($mounts);
			if ($anyMount === null) {
				throw new NotFoundException('Could not fallback to file from mounts');
			}

			$userId = $anyMount->getUser()->getUID();
		}

		try {
			$userFolder = $this->rootFolder->getUserFolder($userId);
		} catch (\OC\User\NoUserException $e) {
			// It is a bit hacky to depend on internal exceptions here. But it is the best we can do for now
			throw new NotFoundException();
		}

		// We currently don't know the path nor care about which file mount it is when getting by id
		// therefore we can take a shortcut on the cached node if we have edit permissions on that
		$file = $userFolder->getFirstNodeById($fileId);
		if ($file instanceof File && $file->getPermissions() & Constants::PERMISSION_UPDATE) {
			return $file;
		}

		// Ideally we'd optimize this part in the future by storing the path and getting the acutal target directly
		$files = $userFolder->getById($fileId);
		if (count($files) === 0) {
			throw new NotFoundException();
		}

		// Workaround to always open files with edit permissions if multiple occurrences of
		// the same file id are in the user home, ideally we should also track the path of the file when opening
		usort($files, static function (Node $a, Node $b) {
			return ($b->getPermissions() & Constants::PERMISSION_UPDATE) <=> ($a->getPermissions() & Constants::PERMISSION_UPDATE);
		});

		$file = array_shift($files);

		if (!$file instanceof File) {
			throw new NotFoundException();
		}

		if (($file->getPermissions() & Constants::PERMISSION_READ) !== Constants::PERMISSION_READ) {
			throw new NotPermittedException();
		}

		return $file;
	}

	/**
	 * @throws NotFoundException
	 */
	public function getFileByShareToken(string $shareToken, ?string $path = null): File {
		try {
			$share = $this->shareManager->getShareByToken($shareToken);
		} catch (ShareNotFound $e) {
			throw new NotFoundException();
		}

		$node = $share->getNode();
		if ($path !== null && $node instanceof Folder) {
			$node = $node->get($path);
		}
		if ($node instanceof File) {
			return $node;
		}
		throw new \InvalidArgumentException('No proper share data');
	}

	public function isReadOnlyCached(Session $session, ?string $shareToken = null): bool {
		$cacheKey = 'read-only-' . $session->getId();
		$isReadOnly = $this->cache->get($cacheKey);
		if ($isReadOnly === null) {
			$file = $this->getFileForSession($session, $shareToken);
			$isReadOnly = $this->isReadOnly($file, $shareToken);
			$this->cache->set($cacheKey, $isReadOnly, 60 * 5);
			return $isReadOnly;
		}

		return $isReadOnly;
	}

	public function isReadOnly(File $file, ?string $token): bool {
		$readOnly = true;
		if ($token !== null) {
			try {
				$this->checkSharePermissions($token, Constants::PERMISSION_UPDATE);
				$readOnly = false;
			} catch (NotFoundException $e) {
			}
		} else {
			$readOnly = !$file->isUpdateable();
		}

		$lockInfo = $this->getLockInfo($file);
		$isTextLock = (
			$lockInfo && $lockInfo->getType() === ILock::TYPE_APP && $lockInfo->getOwner() === Application::APP_NAME
		);

		if ($isTextLock) {
			return $readOnly;
		}

		return $readOnly || $lockInfo !== null;
	}

	public function getLockInfo(File $file): ?ILock {
		try {
			$locks = $this->lockManager->getLocks($file->getId());
		} catch (NoLockProviderException|PreConditionNotMetException $e) {
			return null;
		}
		return array_shift($locks);
	}

	/**
	 * @param $shareToken
	 *
	 * @return void
	 *
	 * @throws NotFoundException|NotPermittedException
	 *
	 * @psalm-param 1|2 $permission
	 */
	public function checkSharePermissions(string $shareToken, int $permission = Constants::PERMISSION_READ): void {
		try {
			$share = $this->shareManager->getShareByToken($shareToken);
		} catch (ShareNotFound $e) {
			throw new NotFoundException();
		}

		if (($share->getPermissions() & $permission) === 0 || ($share->getNode()->getPermissions() & $permission) === 0) {
			throw new NotFoundException();
		}
	}

	public function hasUnsavedChanges(Document $document): bool {
		$stepsVersion = $this->stepMapper->getLatestVersion($document->getId()) ?: 0;
		$docVersion = $document->getLastSavedVersion();
		return $stepsVersion !== $docVersion;
	}

	private function ensureDocumentsFolder(): bool {
		try {
			$this->appData->getFolder('documents');
		} catch (NotFoundException $e) {
			$this->appData->newFolder('documents');
		} catch (\RuntimeException $e) {
			// Do not fail hard
			$this->logger->error($e->getMessage(), ['exception' => $e]);
			return false;
		}

		return true;
	}

	public function lock(int $fileId): bool {
		if (!$this->lockManager->isLockProviderAvailable()) {
			return true;
		}

		try {
			$file = $this->getFileById($fileId, $this->userId);
			$this->lockManager->lock(new LockContext(
				$file,
				ILock::TYPE_APP,
				Application::APP_NAME
			));
		} catch (NoLockProviderException|PreConditionNotMetException|NotFoundException $e) {
		} catch (OwnerLockedException $e) {
			return false;
		}
		return true;
	}

	public function unlock(int $fileId): void {
		if (!$this->lockManager->isLockProviderAvailable()) {
			return;
		}

		try {
			$file = $this->getFileById($fileId, $this->userId);
			$this->lockManager->unlock(new LockContext(
				$file,
				ILock::TYPE_APP,
				Application::APP_NAME
			));
		} catch (NoLockProviderException|PreConditionNotMetException|NotFoundException $e) {
		}
	}

	public function countAll(): int {
		return $this->documentMapper->countAll();
	}

	private function getFullAppFolder(): Folder {
		$appFolder = $this->rootFolder->get('appdata_' . $this->config->getSystemValueString('instanceid', '') . '/text');
		if (!$appFolder instanceof Folder) {
			throw new NotFoundException('Folder not found');
		}
		return $appFolder;
	}

	public function clearAll(): void {
		$this->stepMapper->clearAll();
		$this->sessionMapper->clearAll();
		$this->documentMapper->clearAll();
		try {
			$appFolder = $this->getFullAppFolder();
			$appFolder->get('documents')->move($appFolder->getPath() . '/documents_old_' . time());
		} catch (NotFoundException) {
		}
		$this->ensureDocumentsFolder();
	}

	public function cleanupOldDocumentsFolders(): void {
		try {
			$appFolder = $this->getFullAppFolder();
			foreach ($appFolder->getDirectoryListing() as $node) {
				if (str_starts_with($node->getName(), 'documents_old_')) {
					$node->delete();
				}
			}
		} catch (NotFoundException) {
		}
	}
}
