<?php

declare(strict_types=1);

/*
 * Chill is a software for social workers
 *
 * For the full copyright and license information, please view
 * the LICENSE file that was distributed with this source code.
 */

namespace Chill\DocStoreBundle\AsyncUpload\Driver\OpenstackObjectStore;

use Base64Url\Base64Url;
use Chill\DocStoreBundle\AsyncUpload\TempUrlGeneratorInterface;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
use Chill\DocStoreBundle\Exception\StoredObjectManagerException;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;

final class StoredObjectManager implements StoredObjectManagerInterface
{
    private array $inMemory = [];

    public function __construct(
        private readonly HttpClientInterface $client,
        private readonly TempUrlGeneratorInterface $tempUrlGenerator,
    ) {}

    public function getLastModified(StoredObject|StoredObjectVersion $document): \DateTimeInterface
    {
        $version = $document instanceof StoredObject ? $document->getCurrentVersion() : $document;

        if (null !== $createdAt = $version->getCreatedAt()) {
            // as a createdAt datetime is set, return the date and time from database
            return $createdAt;
        }

        // if no createdAt version exists in the database, we fetch the date and time from the
        // file. This situation happens for files created before July 2024.

        if ($this->hasCache($version)) {
            $response = $this->getResponseFromCache($version);
        } else {
            try {
                $response = $this
                    ->client
                    ->request(
                        Request::METHOD_HEAD,
                        $this
                            ->tempUrlGenerator
                            ->generate(
                                Request::METHOD_HEAD,
                                $version->getFilename()
                            )
                            ->url
                    );
            } catch (TransportExceptionInterface $exception) {
                throw StoredObjectManagerException::errorDuringHttpRequest($exception);
            }
        }

        return $this->extractLastModifiedFromResponse($response);
    }

    public function getContentLength(StoredObject|StoredObjectVersion $document): int
    {
        $version = $document instanceof StoredObject ? $document->getCurrentVersion() : $document;

        if (!$this->isVersionEncrypted($version)) {
            if ($this->hasCache($version)) {
                $response = $this->getResponseFromCache($version);
            } else {
                try {
                    $response = $this
                        ->client
                        ->request(
                            Request::METHOD_HEAD,
                            $this
                                ->tempUrlGenerator
                                ->generate(
                                    Request::METHOD_HEAD,
                                    $version->getFilename()
                                )
                                ->url
                        );
                } catch (TransportExceptionInterface $exception) {
                    throw StoredObjectManagerException::errorDuringHttpRequest($exception);
                }
            }

            return $this->extractContentLengthFromResponse($response);
        }

        return strlen($this->read($document));
    }

    /**
     * @throws TransportExceptionInterface
     * @throws StoredObjectManagerException
     */
    public function exists(StoredObject|StoredObjectVersion $document): bool
    {
        $version = $document instanceof StoredObject ? $document->getCurrentVersion() : $document;

        if ($this->hasCache($version)) {
            return true;
        }
        try {
            $response = $this
                ->client
                ->request(
                    Request::METHOD_HEAD,
                    $this
                        ->tempUrlGenerator
                        ->generate(
                            Request::METHOD_HEAD,
                            $version->getFilename()
                        )
                        ->url
                );

            return 200 === $response->getStatusCode();
        } catch (TransportExceptionInterface $exception) {
            throw StoredObjectManagerException::errorDuringHttpRequest($exception);
        }
    }

    public function etag(StoredObject|StoredObjectVersion $document): string
    {
        $version = $document instanceof StoredObject ? $document->getCurrentVersion() : $document;

        if ($this->hasCache($version)) {
            $response = $this->getResponseFromCache($version);
        } else {
            try {
                $response = $this
                    ->client
                    ->request(
                        Request::METHOD_HEAD,
                        $this
                            ->tempUrlGenerator
                            ->generate(
                                Request::METHOD_HEAD,
                                $version->getFilename()
                            )
                            ->url
                    );
            } catch (TransportExceptionInterface $exception) {
                throw StoredObjectManagerException::errorDuringHttpRequest($exception);
            }
        }

        return $this->extractEtagFromResponse($response);
    }

    public function read(StoredObject|StoredObjectVersion $document, ?int $version = null): string
    {
        $version = $document instanceof StoredObject ? $document->getCurrentVersion() : $document;

        $response = $this->getResponseFromCache($version);

        try {
            $data = $response->getContent();
        } catch (\Throwable $e) {
            throw StoredObjectManagerException::unableToGetResponseContent($e);
        }

        if (!$this->isVersionEncrypted($version)) {
            return $data;
        }

        $clearData = openssl_decrypt(
            $data,
            self::ALGORITHM,
            // TODO: Why using this library and not use base64_decode() ?
            Base64Url::decode($version->getKeyInfos()['k']),
            \OPENSSL_RAW_DATA,
            pack('C*', ...$version->getIv())
        );

        if (false === $clearData) {
            throw StoredObjectManagerException::unableToDecrypt(openssl_error_string());
        }

        return $clearData;
    }

    public function write(StoredObject $document, string $clearContent, ?string $contentType = null): StoredObjectVersion
    {
        $newIv = $document->getIv();
        $newKey = $document->getKeyInfos();
        $newType = $contentType ?? $document->getType();
        $version = $document->registerVersion(
            $newIv,
            $newKey,
            $newType
        );

        $encryptedContent = $this->isVersionEncrypted($version)
            ? openssl_encrypt(
                $clearContent,
                self::ALGORITHM,
                // TODO: Why using this library and not use base64_decode() ?
                Base64Url::decode($version->getKeyInfos()['k']),
                \OPENSSL_RAW_DATA,
                pack('C*', ...$version->getIv())
            )
            : $clearContent;

        $headers = [];

        if (null !== $document->getDeleteAt()) {
            $headers['X-Delete-At'] = $document->getDeleteAt()->getTimestamp();
        }

        try {
            $response = $this
                ->client
                ->request(
                    Request::METHOD_PUT,
                    $this
                        ->tempUrlGenerator
                        ->generate(
                            Request::METHOD_PUT,
                            $version->getFilename()
                        )
                        ->url,
                    [
                        'body' => $encryptedContent,
                        'headers' => $headers,
                    ]
                );
        } catch (TransportExceptionInterface $exception) {
            throw StoredObjectManagerException::errorDuringHttpRequest($exception);
        }

        if (Response::HTTP_CREATED !== $response->getStatusCode()) {
            throw StoredObjectManagerException::invalidStatusCode($response->getStatusCode());
        }

        $this->clearCache();

        return $version;
    }

    /**
     * @throws StoredObjectManagerException
     */
    public function delete(StoredObjectVersion $storedObjectVersion): void
    {
        $signedUrl = $this->tempUrlGenerator->generate('DELETE', $storedObjectVersion->getFilename());

        try {
            $response = $this->client->request('DELETE', $signedUrl->url);
            if (! (Response::HTTP_NO_CONTENT === $response->getStatusCode() || Response::HTTP_NOT_FOUND === $response->getStatusCode())) {
                throw StoredObjectManagerException::invalidStatusCode($response->getStatusCode());
            }

            $storedObjectVersion->getStoredObject()->removeVersion($storedObjectVersion);
        } catch (TransportExceptionInterface $exception) {
            throw StoredObjectManagerException::errorDuringHttpRequest($exception);
        }
    }

    public function clearCache(): void
    {
        $this->inMemory = [];
    }

    private function extractLastModifiedFromResponse(ResponseInterface $response): \DateTimeImmutable
    {
        $lastModifiedString = (($response->getHeaders()['last-modified'] ?? [])[0] ?? '');

        $date = \DateTimeImmutable::createFromFormat(
            \DateTimeImmutable::RFC7231,
            $lastModifiedString,
            new \DateTimeZone('GMT')
        );

        if (false === $date) {
            throw new \RuntimeException('the date from remote storage could not be parsed: '.$lastModifiedString);
        }

        return $date;
    }

    /**
     * Extracts the content length from a ResponseInterface object.
     *
     * Does work only if the object is not encrypted.
     *
     * @return int the extracted content length as an integer
     */
    private function extractContentLengthFromResponse(ResponseInterface $response): int
    {
        return (int) ($response->getHeaders()['content-length'] ?? ['0'])[0];
    }

    private function extractEtagFromResponse(ResponseInterface $response): ?string
    {
        $etag = ($response->getHeaders()['etag'] ?? [''])[0];

        if ('' === $etag) {
            return null;
        }

        return $etag;
    }

    private function fillCache(StoredObjectVersion $document): void
    {
        try {
            $response = $this
                ->client
                ->request(
                    Request::METHOD_GET,
                    $this
                        ->tempUrlGenerator
                        ->generate(
                            Request::METHOD_GET,
                            $document->getFilename()
                        )
                        ->url
                );
        } catch (\Throwable $e) {
            throw StoredObjectManagerException::errorDuringHttpRequest($e);
        }

        if (Response::HTTP_OK !== $response->getStatusCode()) {
            throw StoredObjectManagerException::invalidStatusCode($response->getStatusCode());
        }

        $this->inMemory[$this->buildCacheKey($document)] = $response;
    }

    private function buildCacheKey(StoredObjectVersion $storedObjectVersion): string
    {
        return $storedObjectVersion->getStoredObject()->getUuid()->toString().$storedObjectVersion->getId();
    }

    private function getResponseFromCache(StoredObjectVersion $document): ResponseInterface
    {
        if (!$this->hasCache($document)) {
            $this->fillCache($document);
        }

        return $this->inMemory[$this->buildCacheKey($document)];
    }

    private function hasCache(StoredObjectVersion $document): bool
    {
        return \array_key_exists($this->buildCacheKey($document), $this->inMemory);
    }

    private function isVersionEncrypted(StoredObjectVersion $storedObjectVersion): bool
    {
        return $storedObjectVersion->isEncrypted();
    }
}
