<?php declare(strict_types=1);

/**
 * @author Artur Neumann <artur@jankaritech.com>
 *
 * @copyright Copyright (c) 2020, ownCloud GmbH
 * @license AGPL-3.0
 *
 * This code is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License, version 3,
 * as published by the Free Software Foundation.
 *
 * 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, version 3,
 * along with this program.  If not, see <http://www.gnu.org/licenses/>
 *
 */

use Behat\Behat\Context\Context;
use Behat\Behat\Hook\Scope\BeforeScenarioScope;
use Behat\Gherkin\Node\TableNode;
use GuzzleHttp\Exception\GuzzleException;
use TusPhp\Exception\ConnectionException;
use TusPhp\Exception\TusException;
use Psr\Http\Message\ResponseInterface;
use TestHelpers\HttpRequestHelper;
use TestHelpers\WebDavHelper;
use TestHelpers\BehatHelper;
use TestHelpers\TusClient;

require_once 'bootstrap.php';

/**
 * TUS related test steps
 */
class TUSContext implements Context {
	private FeatureContext $featureContext;

	private array $tusResourceLocations = [];

	/**
	 * @param string $filenameHash
	 * @param string $location
	 *
	 * @return void
	 */
	public function saveTusResourceLocation(string $filenameHash, string $location): void {
		$this->tusResourceLocations[$filenameHash][] = $location;
	}

	/**
	 * @param string $filenameHash
	 * @param int|null $index
	 *
	 * @return string
	 */
	public function getTusResourceLocation(string $filenameHash, ?int $index = null): string {
		if ($index === null) {
			// get the last one
			$index = \count($this->tusResourceLocations[$filenameHash]) - 1;
		}
		return $this->tusResourceLocations[$filenameHash][$index];
	}

	/**
	 * @return string
	 */
	public function getLastTusResourceLocation(): string {
		$lastKey = \array_key_last($this->tusResourceLocations);
		$index = \count($this->tusResourceLocations[$lastKey]) - 1;
		return $this->tusResourceLocations[$lastKey][$index];
	}

	/**
	 * @param string $uploadMetadata
	 *
	 * @return string
	 */
	public function parseFilenameHash(string $uploadMetadata): string {
		$filenameHash = \explode("filename ", $uploadMetadata)[1] ?? '';
		return \explode(" ", $filenameHash, 2)[0];
	}

	/**
	 * @param string $user
	 * @param TableNode $headersTable
	 * @param string $content
	 * @param string|null $spaceId
	 *
	 * @return ResponseInterface
	 *
	 * @throws Exception
	 * @throws GuzzleException
	 */
	public function createNewTUSResourceWithHeaders(
		string $user,
		TableNode $headersTable,
		string $content = '',
		?string $spaceId = null,
	): ResponseInterface {
		$this->featureContext->verifyTableNodeColumnsCount($headersTable, 2);
		$user = $this->featureContext->getActualUsername($user);
		$password = $this->featureContext->getUserPassword($user);

		$headers = $headersTable->getRowsHash();
		$response = $this->featureContext->makeDavRequest(
			$user,
			"POST",
			null,
			$headers,
			$content,
			$spaceId,
			"files",
			null,
			false,
			$password,
		);
		$locationHeader = $response->getHeader('Location');
		if (\sizeof($locationHeader) > 0) {
			$filenameHash = $this->parseFilenameHash($headers['Upload-Metadata']);
			$this->saveTusResourceLocation($filenameHash, $locationHeader[0]);
		}
		return $response;
	}

	/**
	 * @When user :user creates a new TUS resource on the WebDAV API with these headers:
	 *
	 * @param string $user
	 * @param TableNode $headers
	 * @param string $content
	 *
	 * @return void
	 *
	 * @throws Exception
	 * @throws GuzzleException
	 */
	public function userCreateNewTUSResourceWithHeaders(string $user, TableNode $headers, string $content = ''): void {
		$response = $this->createNewTUSResourceWithHeaders($user, $headers, $content);
		$this->featureContext->setResponse($response);
	}

	/**
	 * @Given user :user has created a new TUS resource on the WebDAV API with these headers:
	 *
	 * @param string $user
	 * @param TableNode $headers Tus-Resumable: 1.0.0 header is added automatically
	 *
	 * @return void
	 *
	 * @throws Exception
	 * @throws GuzzleException
	 */
	public function userHasCreatedNewTUSResourceWithHeaders(string $user, TableNode $headers): void {
		$response = $this->createNewTUSResource($user, $headers);
		$this->featureContext->theHTTPStatusCodeShouldBe(201, "", $response);
	}

	/**
	 * @param string $user
	 * @param TableNode $headers
	 * @param string|null $spaceId
	 *
	 * @return ResponseInterface
	 */
	public function createNewTUSResource(string $user, TableNode $headers, ?string $spaceId = null): ResponseInterface {
		$rows = $headers->getRows();
		$rows[] = ['Tus-Resumable', '1.0.0'];
		return $this->createNewTUSResourceWithHeaders($user, new TableNode($rows), '', $spaceId);
	}

	/**
	 * @param string $user
	 * @param string $resourceLocation
	 * @param string $offset
	 * @param string $data
	 * @param string $checksum
	 * @param array|null $extraHeaders
	 *
	 * @return ResponseInterface
	 *
	 * @throws GuzzleException
	 * @throws JsonException
	 */
	public function uploadChunkToTUSLocation(
		string $user,
		string $resourceLocation,
		string $offset,
		string $data,
		string $checksum = '',
		?array $extraHeaders = null,
	): ResponseInterface {
		$user = $this->featureContext->getActualUsername($user);
		$password = $this->featureContext->getUserPassword($user);
		$headers = [
			'Content-Type' => 'application/offset+octet-stream',
			'Tus-Resumable' => '1.0.0',
			'Upload-Checksum' => $checksum,
			'Upload-Offset' => $offset,
		];
		$headers = empty($extraHeaders) ? $headers : array_merge($headers, $extraHeaders);

		return HttpRequestHelper::sendRequest(
			$resourceLocation,
			'PATCH',
			$user,
			$password,
			$headers,
			$data,
		);
	}

	/**
	 * @When user :user sends a chunk to the last created TUS Location with offset :offset and data :data using the WebDAV API
	 *
	 * @param string $user
	 * @param string $offset
	 * @param string $data
	 *
	 * @return void
	 *
	 * @throws GuzzleException
	 * @throws JsonException
	 */
	public function userSendsAChunkToTUSLocationWithOffsetAndData(string $user, string $offset, string $data): void {
		$resourceLocation = $this->getLastTusResourceLocation();
		$response = $this->uploadChunkToTUSLocation($user, $resourceLocation, $offset, $data);
		$this->featureContext->setResponse($response);
	}

	/**
	 * @When user :user uploads file :source to :destination using the TUS protocol on the WebDAV API
	 *
	 * @param string|null $user
	 * @param string $source
	 * @param string $destination
	 * @param array $uploadMetadata array of metadata to be placed in the
	 *                              `Upload-Metadata` header.
	 *                              see https://tus.io/protocols/resumable-upload.html#upload-metadata
	 *                              Don't Base64 encode the value.
	 * @param int $noOfChunks
	 * @param int|null $bytes
	 * @param string $checksum
	 *
	 * @return void
	 * @throws ConnectionException
	 * @throws GuzzleException
	 * @throws JsonException
	 * @throws ReflectionException
	 * @throws TusException
	 */
	public function userUploadsUsingTusAFileTo(
		?string $user,
		string $source,
		string $destination,
		array $uploadMetadata = [],
		int $noOfChunks = 1,
		?int $bytes = null,
		string $checksum = '',
	): void {
		$response = $this->uploadFileUsingTus(
			$user,
			$source,
			$destination,
			null,
			$uploadMetadata,
			$noOfChunks,
			$bytes,
			$checksum,
		);
		$this->featureContext->setLastUploadDeleteTime(\time());
		$this->featureContext->setResponse($response);
	}

	/**
	 * @param string $user
	 * @param string $source
	 * @param string $destination
	 * @param string|null $spaceId
	 * @param array $uploadMetadata
	 * @param integer $noOfChunks
	 * @param integer|null $bytes
	 * @param string $checksum
	 *
	 * @return ResponseInterface
	 */
	public function uploadFileUsingTus(
		?string $user,
		string $source,
		string $destination,
		?string $spaceId = null,
		array $uploadMetadata = [],
		int $noOfChunks = 1,
		?int $bytes = null,
		string $checksum = '',
	): ResponseInterface {
		$user = $this->featureContext->getActualUsername($user);
		$password = $this->featureContext->getUserPassword($user);
		$headers = [
			'Authorization' => 'Basic ' . \base64_encode($user . ':' . $password),
		];
		if ($bytes !== null) {
			$creationWithUploadHeader = [
				'Content-Type' => 'application/offset+octet-stream',
				'Tus-Resumable' => '1.0.0',
			];
			$headers = \array_merge($headers, $creationWithUploadHeader);
		}
		if ($checksum != '') {
			$checksumHeader = [
				'Upload-Checksum' => $checksum,
			];
			$headers = \array_merge($headers, $checksumHeader);
		}

		$davPathVersion = $this->featureContext->getDavPathVersion();
		$suffixPath = $user;
		if ($davPathVersion === WebDavHelper::DAV_VERSION_SPACES) {
			$suffixPath = $spaceId ?: $this->featureContext->getPersonalSpaceIdForUser($user);
		}
		$sourceFile = $this->featureContext->acceptanceTestsDirLocation() . $source;

		$client = $this->createTusClient(
			$this->featureContext->getBaseUrl(),
			$headers,
			$sourceFile,
			$destination,
			WebDavHelper::getDavPath($davPathVersion, $suffixPath),
			$uploadMetadata,
		);

		$this->featureContext->pauseUploadDelete();
		$response = null;

		if ($bytes !== null) {
			return $client->file($sourceFile, $destination)
				->createWithUploadRR($client->getKey(), $bytes);
		} elseif (\filesize($sourceFile) === 0) {
			return $client->file($sourceFile, $destination)->createWithUploadRR($client->getKey(), 0);
		} elseif ($noOfChunks === 1) {
			return $client->file($sourceFile, $destination)->uploadRR();
		}
		$bytesPerChunk = (int)\ceil(\filesize($sourceFile) / $noOfChunks);
		for ($i = 0; $i < $noOfChunks; $i++) {
			$response = $client->uploadRR($bytesPerChunk);
		}
		return $response;
	}

	/**
	 * @param string $source
	 * @param string $destination
	 * @param string $password
	 *
	 * @return ResponseInterface
	 * @throws GuzzleException
	 * @throws ReflectionException
	 */
	public function publicUploadFileUsingTus(
		string $source,
		string $destination,
		string $password,
	): ResponseInterface {
		$password = $this->featureContext->getActualPassword($password);
		if ($this->featureContext->isUsingSharingNG()) {
			$token = $this->featureContext->shareNgGetLastCreatedLinkShareToken();
		} else {
			$token = $this->featureContext->getLastCreatedPublicShareToken();
		}
		$headers = [
			'Authorization' => 'Basic ' . \base64_encode("public:$password"),
		];
		$sourceFile = $this->featureContext->acceptanceTestsDirLocation() . $source;
		$url = WebdavHelper::getDavPath(WebDavHelper::DAV_VERSION_SPACES, $token, "public-files");
		$client = $this->createTusClient(
			$this->featureContext->getBaseUrl(),
			$headers,
			$sourceFile,
			$destination,
			$url,
		);
		$response = $client->createWithUploadRR("", 0);
		return $response;
	}

	/**
	 * Creates the TusClient with API path, headers, metadata, upload key and checksum
	 *
	 * @param string $baseUrl
	 * @param array $headers
	 * @param string $sourceFile
	 * @param string $destination
	 * @param string $path
	 * @param array $metadata
	 *
	 * @return TusClient
	 * @throws ReflectionException
	 */
	private function createTusClient(
		string $baseUrl,
		array $headers,
		string $sourceFile,
		string $destination,
		string $path,
		array $metadata = [],
	): TusClient {
		$client = new TusClient(
			$baseUrl,
			[
				'verify' => false,
				'headers' => $headers,
			],
		);
		$client->setApiPath($path);
		$client->setMetadata($metadata);
		$client->setChecksumAlgorithm('sha1');
		$client->setKey((string)rand())->file($sourceFile, $destination);
		return $client;
	}

	/**
	 * @When user :user uploads file with content :content to :destination using the TUS protocol on the WebDAV API
	 *
	 * @param string $user
	 * @param string $content
	 * @param string $destination
	 *
	 * @return void
	 * @throws GuzzleException
	 * @throws Exception
	 */
	public function userUploadsAFileWithContentToUsingTus(
		string $user,
		string $content,
		string $destination,
	): void {
		$temporaryFileName = $this->writeDataToTempFile($content);
		$response = $this->uploadFileUsingTus(
			$user,
			\basename($temporaryFileName),
			$destination,
		);
		\unlink($temporaryFileName);
		$this->featureContext->setResponse($response);
	}

	/**
	 * @When user :user uploads file with content :content in :noOfChunks chunks to :destination using the TUS protocol on the WebDAV API
	 *
	 * @param string|null $user
	 * @param string $content
	 * @param int|null $noOfChunks
	 * @param string $destination
	 *
	 * @return void
	 * @throws ConnectionException
	 * @throws GuzzleException
	 * @throws JsonException
	 * @throws ReflectionException
	 * @throws TusException
	 * @throws Exception
	 * @throws GuzzleException
	 */
	public function userUploadsAFileWithContentInChunksUsingTus(
		?string $user,
		string $content,
		?int $noOfChunks,
		string $destination,
	): void {
		$temporaryFileName = $this->writeDataToTempFile($content);
		$response = $this->uploadFileUsingTus(
			$user,
			\basename($temporaryFileName),
			$destination,
			null,
			[],
			$noOfChunks,
		);
		$this->featureContext->setLastUploadDeleteTime(\time());
		\unlink($temporaryFileName);
		$this->featureContext->setResponse($response);
	}

	/**
	 * @Given user :user has uploaded file :source to :destination with mtime :mtime using the TUS protocol
	 *
	 * @param string $user
	 * @param string $source
	 * @param string $destination
	 * @param string $mtime Time in human-readable format is taken as input which is converted into milliseconds that is used by API
	 *
	 * @return void
	 * @throws Exception
	 * @throws GuzzleException
	 */
	public function userHasUploadedFileWithMtimeUsingTUS(
		string $user,
		string $source,
		string $destination,
		string $mtime,
	): void {
		$mtime = new DateTime($mtime);
		$mtime = $mtime->format('U');
		$user = $this->featureContext->getActualUsername($user);
		$response = $this->uploadFileUsingTus(
			$user,
			$source,
			$destination,
			null,
			['mtime' => $mtime],
		);
		$this->featureContext->setLastUploadDeleteTime(\time());
		$this->featureContext->theHTTPStatusCodeShouldBe(
			["201", "204"],
			"Failed to upload file '$source' for user '$user'",
			$response,
		);
	}

	/**
	 * @When user :user uploads file :source to :destination with mtime :mtime using the TUS protocol on the WebDAV API
	 *
	 * @param string $user
	 * @param string $source
	 * @param string $destination
	 * @param string $mtime Time in human-readable format is taken as input which is converted into milliseconds that is used by API
	 *
	 * @return void
	 * @throws Exception
	 * @throws GuzzleException
	 */
	public function userUploadsFileWithContentToWithMtimeUsingTUS(
		string $user,
		string $source,
		string $destination,
		string $mtime,
	): void {
		$mtime = new DateTime($mtime);
		$mtime = $mtime->format('U');
		$user = $this->featureContext->getActualUsername($user);
		$response = $this->uploadFileUsingTus(
			$user,
			$source,
			$destination,
			null,
			['mtime' => $mtime],
		);
		$this->featureContext->setLastUploadDeleteTime(\time());
		$this->featureContext->setResponse($response);
	}

	/**
	 * @param string $content
	 *
	 * @return string the file name
	 * @throws Exception
	 */
	public function writeDataToTempFile(string $content): string {
		$temporaryFileName = \tempnam(
			$this->featureContext->acceptanceTestsDirLocation(),
			"tus-upload-test-",
		);
		if ($temporaryFileName === false) {
			throw new \Exception("could not create a temporary filename");
		}
		$temporaryFile = \fopen($temporaryFileName, "w");
		if ($temporaryFile === false) {
			throw new \Exception("could not open " . $temporaryFileName . " for write");
		}
		\fwrite($temporaryFile, $content);
		\fclose($temporaryFile);
		return $temporaryFileName;
	}

	/**
	 * @BeforeScenario
	 *
	 * @param BeforeScenarioScope $scope
	 *
	 * @return void
	 */
	public function before(BeforeScenarioScope $scope): void {
		// Get the environment
		$environment = $scope->getEnvironment();
		// Get all the contexts you need in this context
		$this->featureContext = BehatHelper::getContext($scope, $environment, 'FeatureContext');
		// clear TUS locations cache
		$this->tusResourceLocations = [];
	}

	/**
	 * @When user :user creates a new TUS resource with content :content on the WebDAV API with these headers:
	 *
	 * @param string $user
	 * @param string $content
	 * @param TableNode $headers
	 *
	 * @return void
	 * @throws Exception
	 * @throws GuzzleException
	 */
	public function userCreatesWithUpload(
		string $user,
		string $content,
		TableNode $headers,
	): void {
		$response = $this->createNewTUSResourceWithHeaders($user, $headers, $content);
		$this->featureContext->setResponse($response);
	}

	/**
	 * @When user :user creates file :source and uploads content :content in the same request using the TUS protocol on the WebDAV API
	 *
	 * @param string $user
	 * @param string $source
	 * @param string $content
	 *
	 * @return void
	 * @throws Exception
	 */
	public function userUploadsWithCreatesWithUpload(
		string $user,
		string $source,
		string $content,
	): void {
		$temporaryFileName = $this->writeDataToTempFile($content);
		$response = $this->uploadFileUsingTus(
			$user,
			\basename($temporaryFileName),
			$source,
			null,
			[],
			1,
			-1,
		);
		$this->featureContext->setLastUploadDeleteTime(\time());
		\unlink($temporaryFileName);
		$this->featureContext->setResponse($response);
	}

	/**
	 * @When user :user uploads file with checksum :checksum to the last created TUS Location with offset :offset and content :content using the TUS protocol on the WebDAV API
	 *
	 * @param string $user
	 * @param string $checksum
	 * @param string $offset
	 * @param string $content
	 *
	 * @return void
	 * @throws Exception
	 */
	public function userUploadsFileWithChecksum(
		string $user,
		string $checksum,
		string $offset,
		string $content,
	): void {
		$resourceLocation = $this->getLastTusResourceLocation();
		$response = $this->uploadChunkToTUSLocation($user, $resourceLocation, $offset, $content, $checksum);
		$this->featureContext->setResponse($response);
	}

	/**
	 * @When user :user uploads content :content with checksum :checksum and offset :offset to the index :locationIndex location of file :filename using the TUS protocol
	 * @When user :user tries to upload content :content with checksum :checksum and offset :offset to the index :locationIndex location of file :filename using the TUS protocol
	 *
	 * @param string $user
	 * @param string $content
	 * @param string $checksum
	 * @param string $offset
	 * @param string $locationIndex
	 * @param string $filename
	 *
	 * @return void
	 * @throws Exception
	 */
	public function userUploadsContentWithChecksumAndOffsetToIndexLocationUsingTUSProtocol(
		string $user,
		string $content,
		string $checksum,
		string $offset,
		string $locationIndex,
		string $filename,
	): void {
		$filenameHash = \base64_encode($filename);
		$resourceLocation = $this->getTusResourceLocation($filenameHash, (int)$locationIndex);
		$response = $this->uploadChunkToTUSLocation($user, $resourceLocation, $offset, $content, $checksum);
		$this->featureContext->setResponse($response);
	}

	/**
	 * @Given user :user has uploaded file with checksum :checksum to the last created TUS Location with offset :offset and content :content using the TUS protocol on the WebDAV API
	 *
	 * @param string $user
	 * @param string $checksum
	 * @param string $offset
	 * @param string $content
	 *
	 * @return void
	 * @throws Exception
	 */
	public function userHasUploadedFileWithChecksum(
		string $user,
		string $checksum,
		string $offset,
		string $content,
	): void {
		$resourceLocation = $this->getLastTusResourceLocation();
		$response = $this->uploadChunkToTUSLocation($user, $resourceLocation, $offset, $content, $checksum);
		$this->featureContext->theHTTPStatusCodeShouldBe(204, "", $response);
	}

	/**
	 * @When user :user sends a chunk to the last created TUS Location with offset :offset and data :data with checksum :checksum using the TUS protocol on the WebDAV API
	 *
	 * @param string $user
	 * @param string $offset
	 * @param string $data
	 * @param string $checksum
	 *
	 * @return void
	 * @throws Exception
	 */
	public function userUploadsChunkFileWithChecksum(
		string $user,
		string $offset,
		string $data,
		string $checksum,
	): void {
		$resourceLocation = $this->getLastTusResourceLocation();
		$response = $this->uploadChunkToTUSLocation($user, $resourceLocation, $offset, $data, $checksum);
		$this->featureContext->setResponse($response);
	}

	/**
	 * @Given user :user has uploaded a chunk to the last created TUS Location with offset :offset and data :data with checksum :checksum using the TUS protocol on the WebDAV API
	 *
	 * @param string $user
	 * @param string $offset
	 * @param string $data
	 * @param string $checksum
	 *
	 * @return void
	 * @throws Exception
	 */
	public function userHasUploadedChunkFileWithChecksum(
		string $user,
		string $offset,
		string $data,
		string $checksum,
	): void {
		$resourceLocation = $this->getLastTusResourceLocation();
		$response = $this->uploadChunkToTUSLocation($user, $resourceLocation, $offset, $data, $checksum);
		$this->featureContext->theHTTPStatusCodeShouldBe(204, "", $response);
	}

	/**
	 * @When user :user overwrites recently shared file with offset :offset and data :data with checksum :checksum using the TUS protocol on the WebDAV API with these headers:
	 * @When user :user overwrites existing file with offset :offset and data :data with checksum :checksum using the TUS protocol on the WebDAV API with these headers:
	 *
	 * @param string $user
	 * @param string $offset
	 * @param string $data
	 * @param string $checksum
	 * @param TableNode $headers Tus-Resumable: 1.0.0 header is added automatically
	 *
	 * @return void
	 *
	 * @throws GuzzleException
	 * @throws Exception
	 */
	public function userOverwritesFileWithChecksum(
		string $user,
		string $offset,
		string $data,
		string $checksum,
		TableNode $headers,
	): void {
		$createResponse = $this->createNewTUSResource($user, $headers);
		$this->featureContext->theHTTPStatusCodeShouldBe(201, "", $createResponse);
		$resourceLocation = $this->getLastTusResourceLocation();
		$response = $this->uploadChunkToTUSLocation($user, $resourceLocation, $offset, $data, $checksum);
		$this->featureContext->setResponse($response);
	}
}
