<?php

namespace Friendica\Addon\webdav_storage\src;

use Exception;
use Friendica\Core\Storage\Capability\ICanWriteToStorage;
use Friendica\Core\Storage\Exception\ReferenceStorageException;
use Friendica\Core\Storage\Exception\StorageException;
use Friendica\Network\HTTPClient\Client\HttpClientOptions;
use Friendica\Network\HTTPClient\Capability\ICanSendHttpRequests;
use Friendica\Util\Strings;
use Psr\Log\LoggerInterface;

/**
 * A WebDav Backend Storage class
 */
class WebDav implements ICanWriteToStorage
{
	const NAME = 'WebDav';

	/** @var string */
	private $url;

	/** @var ICanSendHttpRequests */
	private $client;

	/** @var LoggerInterface */
	private $logger;

	/** @var array */
	private $authOptions;

	/**
	 * WebDav constructor
	 *
	 * @param string               $url         The full URL to the webdav endpoint (including the subdirectories)
	 * @param array                $authOptions The authentication options for the http calls ( ['username', 'password', 'auth_type'] )
	 * @param ICanSendHttpRequests $client      The http client for communicating with the WebDav endpoint
	 * @param LoggerInterface      $logger      The standard logging class
	 */
	public function __construct(string $url, array $authOptions, ICanSendHttpRequests $client, LoggerInterface $logger)
	{
		$this->client = $client;
		$this->logger = $logger;

		$this->authOptions = $authOptions;
		$this->url         = $url;
	}

	/**
	 * Split data ref and return file path
	 *
	 * @param string $reference Data reference
	 *
	 * @return string[]
	 */
	private function pathForRef(string $reference): array
	{
		$fold1 = substr($reference, 0, 2);
		$fold2 = substr($reference, 2, 2);
		$file  = substr($reference, 4);

		return [$this->encodePath(implode('/', [$fold1, $fold2, $file])), implode('/', [$fold1, $fold2]), $file];
	}

	/**
	 * URL encodes the given path but keeps the slashes
	 *
	 * @param string $path to encode
	 *
	 * @return string encoded path
	 */
	protected function encodePath(string $path): string
	{
		// slashes need to stay
		return str_replace('%2F', '/', rawurlencode($path));
	}

	/**
	 * Checks if the URL exists
	 *
	 * @param string $uri the URL to check
	 *
	 * @return bool true in case the file/folder exists
	 */
	protected function exists(string $uri): bool
	{
		return $this->client->head($uri, [HttpClientOptions::AUTH => $this->authOptions])->getReturnCode() == 200;
	}

	/**
	 * Checks if a folder has items left
	 *
	 * @param string $uri the URL to check
	 *
	 * @return bool true in case there are items left in the folder
	 */
	protected function hasItems(string $uri): bool
	{
		$dom               = new \DOMDocument('1.0', 'UTF-8');
		$dom->formatOutput = true;
		$root              = $dom->createElementNS('DAV:', 'd:propfind');
		$prop              = $dom->createElement('d:allprop');

		$dom->appendChild($root)->appendChild($prop);

		$opts = [
			HttpClientOptions::AUTH    => $this->authOptions,
			HttpClientOptions::HEADERS => ['Depth' => 1, 'Prefer' => 'return-minimal', 'Content-Type' => 'application/xml'],
			HttpClientOptions::BODY    => $dom->saveXML(),
		];

		$response = $this->client->request('propfind', $uri, $opts);

		$responseDoc = new \DOMDocument();
		$responseDoc->loadXML($response->getBodyString());
		$responseDoc->formatOutput = true;

		$xpath = new \DOMXPath($responseDoc);
		$xpath->registerNamespace('d', 'DAV');
		$result = $xpath->query('//d:multistatus/d:response');

		// returns at least its own directory, so >1
		return $result !== false && count($result) > 1;
	}

	/**
	 * Creates a DAV-collection (= folder) for the given uri
	 *
	 * @param string $uri The uri for creating a DAV-collection
	 *
	 * @return bool true in case the creation was successful (not immutable!)
	 */
	protected function mkcol(string $uri): bool
	{
		return $this->client->request('mkcol', $uri, [HttpClientOptions::AUTH => $this->authOptions])
							->getReturnCode() == 200;
	}

	/**
	 * Checks if the given path exists and if not creates it
	 *
	 * @param string $fullPath the full path (the folder structure after the hostname)
	 */
	protected function checkAndCreatePath(string $fullPath): void
	{
		$finalUrl = $this->url . '/' . trim($fullPath, '/');

		if ($this->exists($finalUrl)) {
			return;
		}

		$pathParts = explode('/', trim($fullPath, '/'));
		$path      = '';

		foreach ($pathParts as $part) {
			$path .= '/' . $part;
			$partUrl = $this->url . $path;
			if (!$this->exists($partUrl)) {
				$this->mkcol($partUrl);
			}
		}
	}

	/**
	 * Checks recursively, if paths are empty and deletes them
	 *
	 * @param string $fullPath the full path (the folder structure after the hostname)
	 *
	 * @throws StorageException In case a directory cannot get deleted
	 */
	protected function checkAndDeletePath(string $fullPath): void
	{
		$pathParts = explode('/', trim($fullPath, '/'));
		$partURL   = '/' . implode('/', $pathParts);

		foreach ($pathParts as $pathPart) {
			$checkUrl = $this->url . $partURL;
			if (!empty($partURL) && !$this->hasItems($checkUrl)) {
				$response = $this->client->request('delete', $checkUrl, [HttpClientOptions::AUTH => $this->authOptions]);

				if (!$response->isSuccess()) {
					if ($response->getReturnCode() == "404") {
						$this->logger->warning('Directory already deleted.', ['uri' => $checkUrl]);
					} else {
						throw new StorageException(sprintf('Unpredicted error for %s: %s', $checkUrl, $response->getError()), $response->getReturnCode());
					}
				}
			}

			$partURL = substr($partURL, 0, -strlen('/' . $pathPart));
		}
	}

	/**
	 * {@inheritDoc}
	 */
	public function get(string $reference): string
	{
		$file = $this->pathForRef($reference);

		$response = $this->client->request('get', $this->url . '/' . $file[0], [HttpClientOptions::AUTH => $this->authOptions]);

		if (!$response->isSuccess()) {
			throw new ReferenceStorageException(sprintf('Invalid reference %s', $reference));
		}

		return $response->getBodyString();
	}

	/**
	 * {@inheritDoc}
	 */
	public function put(string $data, string $reference = ""): string
	{
		if ($reference === '') {
			try {
				$reference = Strings::getRandomHex();
			} catch (Exception $exception) {
				throw new StorageException('Webdav storage failed to generate a random hex', $exception->getCode(), $exception);
			}
		}
		$file = $this->pathForRef($reference);

		$this->checkAndCreatePath($file[1]);

		$opts = [
			HttpClientOptions::BODY => $data,
			HttpClientOptions::AUTH => $this->authOptions,
		];

		$this->client->request('put', $this->url . '/' . $file[0], $opts);

		return $reference;
	}

	/**
	 * {@inheritDoc}
	 */
	public function delete(string $reference)
	{
		$file = $this->pathForRef($reference);

		$response = $this->client->request('delete', $this->url . '/' . $file[0], [HttpClientOptions::AUTH => $this->authOptions]);

		if (!$response->isSuccess()) {
			throw new ReferenceStorageException(sprintf('Invalid reference %s', $reference));
		}

		$this->checkAndDeletePath($file[1]);
	}

	/**
	 * {@inheritDoc}
	 */
	public function __toString(): string
	{
		return self::getName();
	}

	/**
	 * {@inheritDoc}
	 */
	public static function getName(): string
	{
		return self::NAME;
	}
}
