<?php

/**
 * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
 * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
 * SPDX-License-Identifier: AGPL-3.0-only
 */
namespace OCA\Files_External\Lib\Storage;

use Aws\S3\Exception\S3Exception;
use Icewind\Streams\CallbackWrapper;
use Icewind\Streams\CountWrapper;
use Icewind\Streams\IteratorDirectory;
use OC\Files\Cache\CacheEntry;
use OC\Files\ObjectStore\S3ConnectionTrait;
use OC\Files\ObjectStore\S3ObjectTrait;
use OC\Files\Storage\Common;
use OCP\Cache\CappedMemoryCache;
use OCP\Constants;
use OCP\Files\FileInfo;
use OCP\Files\IMimeTypeDetector;
use OCP\Files\NotPermittedException;
use OCP\ICache;
use OCP\ICacheFactory;
use OCP\ITempManager;
use OCP\Server;
use Override;
use Psr\Log\LoggerInterface;

class AmazonS3 extends Common {
	use S3ConnectionTrait;
	use S3ObjectTrait;

	private LoggerInterface $logger;

	/** @var CappedMemoryCache<array|false> */
	private CappedMemoryCache $objectCache;
	/** @var CappedMemoryCache<bool> */
	private CappedMemoryCache $directoryCache;
	/** @var CappedMemoryCache<array> */
	private CappedMemoryCache $filesCache;

	private IMimeTypeDetector $mimeDetector;
	private ICache $memCache;
	private ?bool $versioningEnabled = null;

	public function __construct(array $parameters) {
		parent::__construct($parameters);
		$this->parseParams($parameters);
		// @todo: using `key` here may be problematic with different authentication methods and/or key rotation...
		$this->id = 'amazon::external::' . md5($this->params['hostname'] . ':' . $this->params['bucket'] . ':' . $this->params['key']);
		$this->initCaches();
		$this->mimeDetector = Server::get(IMimeTypeDetector::class);
		/** @var ICacheFactory $cacheFactory */
		$cacheFactory = Server::get(ICacheFactory::class);
		$this->memCache = $cacheFactory->createLocal('s3-external');
		$this->logger = Server::get(LoggerInterface::class);
	}

	private function normalizePath(string $path): string {
		$path = trim($path, '/');

		if ($path === '') {
			$path = '.';
		}

		return $path;
	}

	private function isRoot(string $path): bool {
		return $path === '.';
	}

	private function getCachePath(string $path): string {
		// normalizePath() converts '' to '.' for S3 object keys, but filecache stores the root as ''
		return $this->isRoot($path) ? '' : $path;
	}

	private function cleanKey(string $path): string {
		if ($this->isRoot($path)) {
			return '/';
		}
		return $path;
	}

	private function initCaches(): void {
		$this->objectCache = new CappedMemoryCache(2048);
		$this->directoryCache = new CappedMemoryCache(8192);
		$this->filesCache = new CappedMemoryCache(4096);
	}

	private function clearCaches(): void {
		$this->objectCache->clear();
		$this->directoryCache->clear();
		$this->filesCache->clear();
	}

	private function invalidateCache(string $key): void {
		unset($this->objectCache[$key]);
		$keys = array_keys($this->objectCache->getData());
		$keyLength = strlen($key);
		foreach ($keys as $existingKey) {
			if (substr($existingKey, 0, $keyLength) === $key) {
				unset($this->objectCache[$existingKey]);
			}
		}
		unset($this->filesCache[$key]);
		$keys = array_keys($this->directoryCache->getData());
		$keyLength = strlen($key);
		foreach ($keys as $existingKey) {
			if (substr($existingKey, 0, $keyLength) === $key) {
				unset($this->directoryCache[$existingKey]);
			}
		}
		unset($this->directoryCache[$key]);
	}

	private function headObject(string $key): array|false {
		if (!isset($this->objectCache[$key])) {
			try {
				$this->objectCache[$key] = $this->getConnection()->headObject([
					'Bucket' => $this->bucket,
					'Key' => $key
				] + $this->getServerSideEncryptionParameters())->toArray();
			} catch (S3Exception $e) {
				if ($e->getStatusCode() >= 500) {
					throw $e;
				}
				$this->objectCache[$key] = false;
			}
		}

		if (is_array($this->objectCache[$key]) && !isset($this->objectCache[$key]['Key'])) {
			/** @psalm-suppress InvalidArgument Psalm doesn't understand nested arrays well */
			$this->objectCache[$key]['Key'] = $key;
		}
		return $this->objectCache[$key];
	}

	/**
	 * Return true if directory exists
	 *
	 * There are no folders in s3. A folder like structure could be archived
	 * by prefixing files with the folder name.
	 *
	 * Implementation from flysystem-aws-s3-v3:
	 * https://github.com/thephpleague/flysystem-aws-s3-v3/blob/8241e9cc5b28f981e0d24cdaf9867f14c7498ae4/src/AwsS3Adapter.php#L670-L694
	 *
	 * @throws \Exception
	 */
	private function doesDirectoryExist(string $path): bool {
		if ($path === '.' || $path === '') {
			return true;
		}
		$path = rtrim($path, '/') . '/';

		if (isset($this->directoryCache[$path])) {
			return $this->directoryCache[$path];
		}
		try {
			// Maybe this isn't an actual key, but a prefix.
			// Do a prefix listing of objects to determine.
			$result = $this->getConnection()->listObjectsV2([
				'Bucket' => $this->bucket,
				'Prefix' => $path,
				'MaxKeys' => 1,
			]);

			if (isset($result['Contents'])) {
				$this->directoryCache[$path] = true;
				return true;
			}

			// empty directories have their own object
			$object = $this->headObject($path);

			if ($object) {
				$this->directoryCache[$path] = true;
				return true;
			}
		} catch (S3Exception $e) {
			if ($e->getStatusCode() >= 400 && $e->getStatusCode() < 500) {
				$this->directoryCache[$path] = false;
			}
			throw $e;
		}


		$this->directoryCache[$path] = false;
		return false;
	}

	#[\Override]
	protected function remove(string $path): bool {
		// remember fileType to reduce http calls
		$fileType = $this->filetype($path);
		if ($fileType === 'dir') {
			return $this->rmdir($path);
		} elseif ($fileType === 'file') {
			return $this->unlink($path);
		} else {
			return false;
		}
	}

	#[\Override]
	public function mkdir(string $path): bool {
		$path = $this->normalizePath($path);

		if ($this->is_dir($path)) {
			return false;
		}

		try {
			$this->getConnection()->putObject([
				'Bucket' => $this->bucket,
				'Key' => $path . '/',
				'Body' => '',
				'ContentType' => FileInfo::MIMETYPE_FOLDER
			] + $this->getServerSideEncryptionParameters());
			$this->testTimeout();
		} catch (S3Exception $e) {
			$this->logger->error($e->getMessage(), [
				'app' => 'files_external',
				'exception' => $e,
			]);
			return false;
		}

		$this->invalidateCache($path);

		return true;
	}

	#[\Override]
	public function file_exists(string $path): bool {
		return $this->filetype($path) !== false;
	}


	#[\Override]
	public function rmdir(string $path): bool {
		$path = $this->normalizePath($path);

		if ($this->isRoot($path)) {
			return $this->clearBucket();
		}

		if (!$this->file_exists($path)) {
			return false;
		}

		$this->invalidateCache($path);
		return $this->batchDelete($path);
	}

	protected function clearBucket(): bool {
		$this->clearCaches();
		return $this->batchDelete();
	}

	private function batchDelete(?string $path = null): bool {
		// TODO explore using https://docs.aws.amazon.com/aws-sdk-php/v3/api/class-Aws.S3.BatchDelete.html
		$params = [
			'Bucket' => $this->bucket
		];
		if ($path !== null) {
			$params['Prefix'] = $path . '/';
		}
		try {
			$connection = $this->getConnection();
			// Since there are no real directories on S3, we need
			// to delete all objects prefixed with the path.
			do {
				// instead of the iterator, manually loop over the list ...
				$objects = $connection->listObjectsV2($params);
				// ... so we can delete the files in batches
				if (isset($objects['Contents'])) {
					$connection->deleteObjects([
						'Bucket' => $this->bucket,
						'Delete' => [
							'Quiet' => true,
							'Objects' => array_map(fn (array $object) => [
								'Key' => $object['Key'],
							], $objects['Contents'])
						]
					]);
					$this->testTimeout();
				}
				// we reached the end when the list is no longer truncated
			} while ($objects['IsTruncated']);
			if ($path !== '' && $path !== null) {
				$this->deleteObject($path);
			}
		} catch (S3Exception $e) {
			$this->logger->error($e->getMessage(), [
				'app' => 'files_external',
				'exception' => $e,
			]);
			return false;
		}
		return true;
	}

	#[\Override]
	public function opendir(string $path) {
		try {
			$content = iterator_to_array($this->getDirectoryContent($path));
			return IteratorDirectory::wrap(array_map(function (array $item) {
				return $item['name'];
			}, $content));
		} catch (S3Exception $e) {
			return false;
		}
	}

	#[\Override]
	public function stat(string $path): array|false {
		$path = $this->normalizePath($path);

		if ($this->is_dir($path)) {
			$stat = $this->getDirectoryMetaData($path);
		} else {
			$object = $this->headObject($path);
			if ($object === false) {
				return false;
			}
			$stat = $this->objectToMetaData($object);
		}
		$stat['atime'] = time();

		return $stat;
	}

	#[\Override]
	public function getMetaData(string $path): ?array {
		$data = parent::getMetaData($path);
		if ($data !== null && $data['mimetype'] === FileInfo::MIMETYPE_FOLDER) {
			// Common::getMetaData sets storage_mtime = mtime, but for S3 virtual directories
			// mtime may have been updated by mtime propagation while storage_mtime should
			// reflect the actual last storage change. Without this override the scanner sees
			// data['storage_mtime'] != cacheData['storage_mtime'] and re-writes the cache,
			// causing View::getCacheEntry to trigger propagateChange on every read.
			$path = $this->normalizePath($path);
			$cacheEntry = $this->getCache()->get($this->getCachePath($path));
			if ($cacheEntry instanceof CacheEntry) {
				$data['storage_mtime'] = $cacheEntry->getStorageMTime();
			} elseif (!$this->isRoot($path) && $directoryMarker = $this->headObject($path . '/')) {
				if (isset($directoryMarker['LastModified'])) {
					$data['storage_mtime'] = strtotime($directoryMarker['LastModified']);
				}
			}
		}
		return $data;
	}

	#[\Override]
	public function is_dir(string $path): bool {
		$path = $this->normalizePath($path);

		try {
			return $this->doesDirectoryExist($path);
		} catch (S3Exception $e) {
			$this->logger->error($e->getMessage(), [
				'app' => 'files_external',
				'exception' => $e,
			]);
			return false;
		}
	}

	#[\Override]
	public function filetype(string $path): string|false {
		$path = $this->normalizePath($path);

		if ($this->isRoot($path)) {
			return 'dir';
		}

		try {
			if (isset($this->directoryCache[$path]) && $this->directoryCache[$path]) {
				return 'dir';
			}
			if ($this->headObject($path)) {
				return 'file';
			}
			if ($this->doesDirectoryExist($path)) {
				return 'dir';
			}
		} catch (S3Exception $e) {
			$this->logger->error($e->getMessage(), [
				'app' => 'files_external',
				'exception' => $e,
			]);
			return false;
		}

		return false;
	}

	#[\Override]
	public function getPermissions(string $path): int {
		$type = $this->filetype($path);
		if (!$type) {
			return 0;
		}
		return $type === 'dir' ? Constants::PERMISSION_ALL : Constants::PERMISSION_ALL - Constants::PERMISSION_CREATE;
	}

	#[\Override]
	public function unlink(string $path): bool {
		$path = $this->normalizePath($path);

		if ($this->is_dir($path)) {
			return $this->rmdir($path);
		}

		try {
			$this->deleteObject($path);
			$this->invalidateCache($path);
		} catch (S3Exception $e) {
			$this->logger->error($e->getMessage(), [
				'app' => 'files_external',
				'exception' => $e,
			]);
			return false;
		}

		return true;
	}

	#[\Override]
	public function fopen(string $path, string $mode) {
		$path = $this->normalizePath($path);

		switch ($mode) {
			case 'r':
			case 'rb':
				// Don't try to fetch empty files
				$stat = $this->stat($path);
				if (is_array($stat) && isset($stat['size']) && $stat['size'] === 0) {
					return fopen('php://memory', $mode);
				}

				try {
					return $this->readObject($path);
				} catch (\Exception $e) {
					$this->logger->error($e->getMessage(), [
						'app' => 'files_external',
						'exception' => $e,
					]);
					return false;
				}
			case 'w':
			case 'wb':
				$tmpFile = Server::get(ITempManager::class)->getTemporaryFile();

				$handle = fopen($tmpFile, 'w');
				return CallbackWrapper::wrap($handle, null, null, function () use ($path, $tmpFile): void {
					$this->writeBack($tmpFile, $path);
				});
			case 'a':
			case 'ab':
			case 'r+':
			case 'w+':
			case 'wb+':
			case 'a+':
			case 'x':
			case 'x+':
			case 'c':
			case 'c+':
				if (strrpos($path, '.') !== false) {
					$ext = substr($path, strrpos($path, '.'));
				} else {
					$ext = '';
				}
				$tmpFile = Server::get(ITempManager::class)->getTemporaryFile($ext);
				if ($this->file_exists($path)) {
					$source = $this->readObject($path);
					file_put_contents($tmpFile, $source);
				}

				$handle = fopen($tmpFile, $mode);
				return CallbackWrapper::wrap($handle, null, null, function () use ($path, $tmpFile): void {
					$this->writeBack($tmpFile, $path);
				});
		}
		return false;
	}

	#[\Override]
	public function touch(string $path, ?int $mtime = null): bool {
		if (is_null($mtime)) {
			$mtime = time();
		}
		$metadata = [
			'lastmodified' => gmdate(\DateTime::RFC1123, $mtime)
		];

		try {
			if ($this->file_exists($path)) {
				return false;
			}

			$mimeType = $this->mimeDetector->detectPath($path);
			$this->getConnection()->putObject([
				'Bucket' => $this->bucket,
				'Key' => $this->cleanKey($path),
				'Metadata' => $metadata,
				'Body' => '',
				'ContentType' => $mimeType,
				'MetadataDirective' => 'REPLACE',
			] + $this->getServerSideEncryptionParameters());
			$this->testTimeout();
		} catch (S3Exception $e) {
			$this->logger->error($e->getMessage(), [
				'app' => 'files_external',
				'exception' => $e,
			]);
			return false;
		}

		$this->invalidateCache($path);
		return true;
	}

	#[\Override]
	public function copy(string $source, string $target, ?bool $isFile = null): bool {
		$source = $this->normalizePath($source);
		$target = $this->normalizePath($target);

		if ($isFile === true || $this->is_file($source)) {
			try {
				$this->copyObject($source, $target, [
					'StorageClass' => $this->storageClass,
				]);
				$this->testTimeout();
			} catch (S3Exception $e) {
				$this->logger->error($e->getMessage(), [
					'app' => 'files_external',
					'exception' => $e,
				]);
				return false;
			}
		} else {
			$this->remove($target);

			try {
				$this->mkdir($target);
				$this->testTimeout();
			} catch (S3Exception $e) {
				$this->logger->error($e->getMessage(), [
					'app' => 'files_external',
					'exception' => $e,
				]);
				return false;
			}

			foreach ($this->getDirectoryContent($source) as $item) {
				$childSource = $source . '/' . $item['name'];
				$childTarget = $target . '/' . $item['name'];
				$this->copy($childSource, $childTarget, $item['mimetype'] !== FileInfo::MIMETYPE_FOLDER);
			}
		}

		$this->invalidateCache($target);

		return true;
	}

	#[\Override]
	public function rename(string $source, string $target): bool {
		$source = $this->normalizePath($source);
		$target = $this->normalizePath($target);

		if ($this->is_file($source)) {
			if ($this->copy($source, $target) === false) {
				return false;
			}

			if ($this->unlink($source) === false) {
				$this->unlink($target);
				return false;
			}
		} else {
			if ($this->copy($source, $target) === false) {
				return false;
			}

			if ($this->rmdir($source) === false) {
				$this->rmdir($target);
				return false;
			}
		}

		return true;
	}

	#[\Override]
	public function test(): bool {
		$this->getConnection()->headBucket([
			'Bucket' => $this->bucket
		]);
		return true;
	}

	#[\Override]
	public function getId(): string {
		return $this->id;
	}

	public function writeBack(string $tmpFile, string $path): bool {
		try {
			$source = fopen($tmpFile, 'r');
			$this->writeObject($path, $source, $this->mimeDetector->detectPath($path));
			$this->invalidateCache($path);

			unlink($tmpFile);
			return true;
		} catch (S3Exception $e) {
			$this->logger->error($e->getMessage(), [
				'app' => 'files_external',
				'exception' => $e,
			]);
			return false;
		}
	}

	/**
	 * check if curl is installed
	 */
	public static function checkDependencies(): bool {
		return true;
	}

	#[\Override]
	public function getDirectoryContent(string $directory): \Traversable {
		$path = $this->normalizePath($directory);

		if ($this->isRoot($path)) {
			$path = '';
		} else {
			$path .= '/';
		}

		$results = $this->getConnection()->getPaginator('ListObjectsV2', [
			'Bucket' => $this->bucket,
			'Delimiter' => '/',
			'Prefix' => $path,
		]);

		foreach ($results as $result) {
			// sub folders
			if (is_array($result['CommonPrefixes'])) {
				foreach ($result['CommonPrefixes'] as $prefix) {
					if (preg_match('/\/{2,}$/', $prefix['Prefix'])) {
						$this->logger->warning('Detected a repeating delimiter in prefix \'' . $prefix['Prefix']
											   . '\'. This is unsupported and its contents have been ignored.');
						continue;
					}

					$dir = $this->getDirectoryMetaData($prefix['Prefix']);
					if ($dir) {
						yield $dir;
					}
				}
			}
			if (is_array($result['Contents'])) {
				foreach ($result['Contents'] as $object) {
					$this->objectCache[$object['Key']] = $object;
					if ($object['Key'] !== $path) {
						yield $this->objectToMetaData($object);
					}
				}
			}
		}
	}

	private function objectToMetaData(array $object): array {
		$mtime = isset($object['LastModified']) ? strtotime($object['LastModified']) : time();
		$etag = isset($object['ETag']) ? trim($object['ETag'], '"') : '';
		return [
			'name' => basename($object['Key']),
			'mimetype' => $this->mimeDetector->detectPath($object['Key']),
			'mtime' => $mtime,
			'storage_mtime' => $mtime,
			'etag' => $etag,
			'permissions' => Constants::PERMISSION_ALL - Constants::PERMISSION_CREATE,
			'size' => (int)($object['Size'] ?? $object['ContentLength']),
		];
	}

	private function getDirectoryMetaData(string $path): ?array {
		$path = trim($path, '/');
		// when versioning is enabled, delete markers are returned as part of CommonPrefixes
		// resulting in "ghost" folders, verify that each folder actually exists
		if ($this->versioningEnabled() && !$this->doesDirectoryExist($path)) {
			return null;
		}
		$cacheEntry = $this->getCache()->get($this->getCachePath($path));
		if ($cacheEntry instanceof CacheEntry) {
			return $cacheEntry->getData();
		} else {
			return [
				'name' => basename($path),
				'mimetype' => FileInfo::MIMETYPE_FOLDER,
				'mtime' => time(),
				'storage_mtime' => time(),
				'etag' => uniqid(),
				'permissions' => Constants::PERMISSION_ALL,
				'size' => -1,
			];
		}
	}

	public function versioningEnabled(): bool {
		if ($this->versioningEnabled === null) {
			$cached = $this->memCache->get('versioning-enabled::' . $this->getBucket());
			if ($cached === null) {
				$this->versioningEnabled = $this->getVersioningStatusFromBucket();
				$this->memCache->set('versioning-enabled::' . $this->getBucket(), $this->versioningEnabled, 60);
			} else {
				$this->versioningEnabled = $cached;
			}
		}
		return $this->versioningEnabled;
	}

	protected function getVersioningStatusFromBucket(): bool {
		try {
			$result = $this->getConnection()->getBucketVersioning(['Bucket' => $this->getBucket()]);
			return $result->get('Status') === 'Enabled';
		} catch (S3Exception $s3Exception) {
			// This is needed for compatibility with Storj gateway which does not support versioning yet
			if ($s3Exception->getAwsErrorCode() === 'NotImplemented' || $s3Exception->getAwsErrorCode() === 'AccessDenied') {
				return false;
			}
			throw $s3Exception;
		}
	}

	#[\Override]
	public function hasUpdated(string $path, int $time): bool {
		// for files we can get the proper mtime
		if ($path !== '' && $object = $this->headObject($path)) {
			$stat = $this->objectToMetaData($object);
			return $stat['mtime'] > $time;
		} else {
			// for directories, the only real option we have is to do a prefix listing and iterate over all objects
			// however, since this is just as expensive as just re-scanning the directory, we can simply return true
			// and have the scanner figure out if anything has actually changed
			return true;
		}
	}

	#[\Override]
	public function needsPartFile(): bool {
		// handled natively by the S3 backend/client integration
		return false;
	}

	#[\Override]
	public function writeStream(string $path, $stream, ?int $size = null): int {
		if ($size === null) {
			$size = 0;
			// track the number of bytes read from the input stream to return as the number of written bytes.
			$stream = CountWrapper::wrap($stream, function (int $writtenSize) use (&$size): void {
				$size = $writtenSize;
			});
		}

		if (!is_resource($stream)) {
			throw new \InvalidArgumentException('Invalid stream provided');
		}

		$path = $this->normalizePath($path);
		try {
			$this->writeObject($path, $stream, $this->mimeDetector->detectPath($path));
		} catch (S3Exception $exception) {
			$this->logger->error($exception->getMessage(), [
				'app' => 'files_external',
				'exception' => $exception,
			]);
			throw new NotPermittedException($exception->getMessage(), $exception->getCode(), $exception);
		}
		$this->invalidateCache($path);

		return $size;
	}

	#[Override]
	public function getDirectDownload(string $path): array|false {
		if (!$this->isUsePresignedUrl()) {
			return false;
		}

		$command = $this->getConnection()->getCommand('GetObject', [
			'Bucket' => $this->bucket,
			'Key' => $path,
		]);
		$expiration = new \DateTimeImmutable('+60 minutes');

		try {
			// generate a presigned URL that expires after $expiration time
			$presignedUrl = (string)$this->getConnection()->createPresignedRequest($command, $expiration, [
				'signPayload' => true,
			])->getUri();
		} catch (S3Exception $exception) {
			$this->logger->error($exception->getMessage(), [
				'app' => 'files_external',
				'exception' => $exception,
			]);
			return false;
		}
		return [
			'url' => $presignedUrl,
			'expiration' => $expiration->getTimestamp(),
		];
	}

	#[Override]
	public function getDirectDownloadById(string $fileId): array|false {
		if (!$this->isUsePresignedUrl()) {
			return false;
		}

		$entry = $this->getCache()->get((int)$fileId);
		return $this->getDirectDownload($entry->getPath());
	}
}
