<?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\Tests\AsyncUpload\Driver\OpenstackObjectStore;

use Chill\DocStoreBundle\AsyncUpload\Driver\OpenstackObjectStore\StoredObjectManager;
use Chill\DocStoreBundle\AsyncUpload\SignedUrl;
use Chill\DocStoreBundle\AsyncUpload\TempUrlGeneratorInterface;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Exception\StoredObjectManagerException;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\Exception\TransportException;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Contracts\HttpClient\HttpClientInterface;

/**
 * @internal
 *
 * @covers \Chill\DocStoreBundle\AsyncUpload\Driver\OpenstackObjectStore\StoredObjectManager
 */
final class StoredObjectManagerTest extends TestCase
{
    /**
     * @dataProvider getDataProviderForRead
     */
    public function testRead(StoredObject $storedObject, string $encodedContent, string $clearContent, ?string $exceptionClass = null)
    {
        if (null !== $exceptionClass) {
            $this->expectException($exceptionClass);
        }

        $storedObjectManager = $this->getSubject($storedObject, $encodedContent);

        self::assertEquals($clearContent, $storedObjectManager->read($storedObject));
    }

    public static function getDataProviderForRead(): \Generator
    {
        /* HAPPY SCENARIO */

        // Encrypted object
        yield [
            (new StoredObject())
                ->registerVersion(
                    unpack('C*', 'abcdefghijklmnop'),
                    ['k' => base64_encode('S9NIHMaFHOWzLPez3jZOIHBaNfBrMQUR5zvqBz6kme8')],
                    filename: 'encrypted.txt'
                )->getStoredObject(),
            hex2bin('741237d255fd4f7eddaaa9058912a84caae28a41b10b34d4e3e3abe41d3b9b47cb0dd8f22c3c883d4f0e9defa75ff662'), // Binary encoded string
            'The quick brown fox jumps over the lazy dog', // clear
        ];

        // Non-encrypted object
        yield [
            (new StoredObject())->registerVersion(filename: 'non-encrypted.txt')->getStoredObject(), // The StoredObject
            'The quick brown fox jumps over the lazy dog', // Encrypted
            'The quick brown fox jumps over the lazy dog', // Clear
        ];

        /* UNHAPPY SCENARIO */

        // Encrypted object with issue during HTTP communication
        yield [
            (new StoredObject())
                ->registerVersion(
                    unpack('C*', 'abcdefghijklmnop'),
                    ['k' => base64_encode('S9NIHMaFHOWzLPez3jZOIHBaNfBrMQUR5zvqBz6kme8')],
                    filename: 'error_during_http_request.txt'
                )->getStoredObject(),
            hex2bin('741237d255fd4f7eddaaa9058912a84caae28a41b10b34d4e3e3abe41d3b9b47cb0dd8f22c3c883d4f0e9defa75ff662'), // Binary encoded string
            'The quick brown fox jumps over the lazy dog', // clear
            StoredObjectManagerException::class,
        ];

        // Encrypted object with issue during HTTP communication: Invalid status code
        yield [
            (new StoredObject())
                ->registerVersion(
                    unpack('C*', 'abcdefghijklmnop'),
                    ['k' => base64_encode('S9NIHMaFHOWzLPez3jZOIHBaNfBrMQUR5zvqBz6kme8')],
                    filename: 'invalid_statuscode.txt'
                )->getStoredObject(),
            hex2bin('741237d255fd4f7eddaaa9058912a84caae28a41b10b34d4e3e3abe41d3b9b47cb0dd8f22c3c883d4f0e9defa75ff662'), // Binary encoded string
            'The quick brown fox jumps over the lazy dog', // clear
            StoredObjectManagerException::class,
        ];

        // Erroneous encrypted: Unable to decrypt exception.
        yield [
            (new StoredObject())
                ->registerVersion(
                    unpack('C*', 'abcdefghijklmnop'),
                    ['k' => base64_encode('WRONG_PASS_PHRASE')],
                    filename: 'unable_to_decrypt.txt'
                )->getStoredObject(),
            'WRONG_ENCODED_VALUE', // Binary encoded string
            'The quick brown fox jumps over the lazy dog', // clear
            StoredObjectManagerException::class,
        ];
    }

    /**
     * @dataProvider getDataProviderForWrite
     */
    public function testWrite(StoredObject $storedObject, string $encodedContent, string $clearContent, ?string $exceptionClass = null, ?int $errorCode = null)
    {
        if (null !== $exceptionClass) {
            $this->expectException($exceptionClass);
        }

        $previousVersion = $storedObject->getCurrentVersion();
        $previousFilename = $previousVersion->getFilename();

        $client = new MockHttpClient(function ($method, $url, $options) use ($encodedContent, $previousFilename, $errorCode) {
            self::assertEquals('PUT', $method);
            self::assertStringStartsWith('https://example.com/', $url);
            self::assertStringNotContainsString($previousFilename, $url, 'test that the PUT operation is not performed on the same file');
            self::assertArrayHasKey('body', $options);
            self::assertEquals($encodedContent, $options['body']);

            if (-1 === $errorCode) {
                throw new TransportException();
            }

            return new MockResponse('', ['http_code' => $errorCode ?? 201]);
        });

        $storedObjectManager = new StoredObjectManager($client, $this->getTempUrlGenerator($storedObject));

        $newVersion = $storedObjectManager->write($storedObject, $clearContent);

        self::assertNotSame($previousVersion, $newVersion);
        self::assertSame($storedObject->getCurrentVersion(), $newVersion);
    }

    public static function getDataProviderForWrite(): \Generator
    {
        /* HAPPY SCENARIO */

        // Encrypted object
        yield [
            (new StoredObject())
                ->registerVersion(
                    unpack('C*', 'abcdefghijklmnop'),
                    ['k' => base64_encode('S9NIHMaFHOWzLPez3jZOIHBaNfBrMQUR5zvqBz6kme8')],
                    filename: 'encrypted.txt'
                )->getStoredObject(),
            hex2bin('741237d255fd4f7eddaaa9058912a84caae28a41b10b34d4e3e3abe41d3b9b47cb0dd8f22c3c883d4f0e9defa75ff662'), // Binary encoded string
            'The quick brown fox jumps over the lazy dog', // clear
        ];

        // Non-encrypted object
        yield [
            (new StoredObject())->registerVersion(filename: 'non-encrypted.txt')->getStoredObject(), // The StoredObject
            'The quick brown fox jumps over the lazy dog', // Encrypted
            'The quick brown fox jumps over the lazy dog', // Clear
        ];

        /* UNHAPPY SCENARIO */

        // Encrypted object with issue during HTTP communication
        yield [
            (new StoredObject())
                ->registerVersion(
                    unpack('C*', 'abcdefghijklmnop'),
                    ['k' => base64_encode('S9NIHMaFHOWzLPez3jZOIHBaNfBrMQUR5zvqBz6kme8')],
                    filename: 'error_during_http_request.txt'
                )->getStoredObject(),
            hex2bin('741237d255fd4f7eddaaa9058912a84caae28a41b10b34d4e3e3abe41d3b9b47cb0dd8f22c3c883d4f0e9defa75ff662'), // Binary encoded string
            'The quick brown fox jumps over the lazy dog', // clear
            StoredObjectManagerException::class,
            -1,
        ];

        // Encrypted object with issue during HTTP communication: Invalid status code
        yield [
            (new StoredObject())
                ->registerVersion(
                    unpack('C*', 'abcdefghijklmnop'),
                    ['k' => base64_encode('S9NIHMaFHOWzLPez3jZOIHBaNfBrMQUR5zvqBz6kme8')],
                    filename: 'invalid_statuscode.txt'
                )->getStoredObject(),
            hex2bin('741237d255fd4f7eddaaa9058912a84caae28a41b10b34d4e3e3abe41d3b9b47cb0dd8f22c3c883d4f0e9defa75ff662'), // Binary encoded string
            'The quick brown fox jumps over the lazy dog', // clear
            StoredObjectManagerException::class,
            408,
        ];
    }

    public function testDelete(): void
    {
        $storedObject = new StoredObject();
        $version = $storedObject->registerVersion(filename: 'object_name');

        $httpClient = new MockHttpClient(function ($method, $url, $options) {
            self::assertEquals('DELETE', $method);
            self::assertEquals('https://example.com/object_name', $url);

            return new MockResponse('', [
                'http_code' => 204,
            ]);
        });

        $tempUrlGenerator = $this->createMock(TempUrlGeneratorInterface::class);
        $tempUrlGenerator
            ->expects($this->once())
            ->method('generate')
            ->with($this->identicalTo('DELETE'), $this->identicalTo('object_name'))
            ->willReturnCallback(fn (string $method, string $objectName) => new SignedUrl(
                $method,
                'https://example.com/'.$objectName,
                new \DateTimeImmutable('1 hours'),
                $objectName
            ));

        $storedObjectManager = new StoredObjectManager($httpClient, $tempUrlGenerator);
        $storedObjectManager->delete($version);
    }

    public function testWriteWithDeleteAt()
    {
        $storedObject = new StoredObject();

        $expectedRequests = [
            function ($method, $url, $options): MockResponse {
                self::assertEquals('PUT', $method);
                self::assertArrayHasKey('headers', $options);
                self::assertIsArray($options['headers']);
                self::assertCount(0, array_filter($options['headers'], fn (string $header) => str_contains($header, 'X-Delete-At')));

                return new MockResponse('', ['http_code' => 201]);
            },

            function ($method, $url, $options): MockResponse {
                self::assertEquals('PUT', $method);
                self::assertArrayHasKey('headers', $options);
                self::assertIsArray($options['headers']);
                self::assertCount(1, array_filter($options['headers'], fn (string $header) => str_contains($header, 'X-Delete-At')));
                self::assertContains('X-Delete-At: 1711014260', $options['headers']);

                return new MockResponse('', ['http_code' => 201]);
            },
        ];
        $client = new MockHttpClient($expectedRequests);

        $manager = new StoredObjectManager($client, $this->getTempUrlGenerator($storedObject));

        $manager->write($storedObject, 'ok');

        // with a deletedAt date
        $storedObject->setDeleteAt(\DateTimeImmutable::createFromFormat('U', '1711014260'));
        $manager->write($storedObject, 'ok');
    }

    public function testGetLastModifiedWithDateTimeInEntity(): void
    {
        $version = ($storedObject = new StoredObject())->registerVersion();
        $version->setCreatedAt(new \DateTimeImmutable('2024-07-09 15:09:47', new \DateTimeZone('+00:00')));
        $client = $this->createMock(HttpClientInterface::class);
        $client->expects(self::never())->method('request')->withAnyParameters();

        $tempUrlGenerator = $this->createMock(TempUrlGeneratorInterface::class);
        $tempUrlGenerator
            ->expects($this->never())
            ->method('generate')
            ->with($this->identicalTo('HEAD'), $this->isType('string'))
            ->willReturnCallback(fn (string $method, string $objectName) => new SignedUrl(
                $method,
                'https://example.com/'.$objectName,
                new \DateTimeImmutable('1 hours'),
                $objectName
            ));

        $manager = new StoredObjectManager($client, $tempUrlGenerator);

        $actual = $manager->getLastModified($storedObject);

        self::assertEquals(new \DateTimeImmutable('2024-07-09 15:09:47 GMT'), $actual);
    }

    public function testGetLastModifiedWithDateTimeFromResponse(): void
    {
        $storedObject = (new StoredObject())->registerVersion()->getStoredObject();

        $client = new MockHttpClient(
            new MockResponse('', ['http_code' => 200, 'response_headers' => [
                'last-modified' => 'Tue, 09 Jul 2024 15:09:47 GMT',
            ]])
        );

        $tempUrlGenerator = $this->createMock(TempUrlGeneratorInterface::class);
        $tempUrlGenerator
            ->expects($this->atLeastOnce())
            ->method('generate')
            ->with($this->identicalTo('HEAD'), $this->isType('string'))
            ->willReturnCallback(fn (string $method, string $objectName) => new SignedUrl(
                $method,
                'https://example.com/'.$objectName,
                new \DateTimeImmutable('1 hours'),
                $objectName
            ));

        $manager = new StoredObjectManager($client, $tempUrlGenerator);

        $actual = $manager->getLastModified($storedObject);

        self::assertEquals(new \DateTimeImmutable('2024-07-09 15:09:47 GMT'), $actual);
    }

    private function getHttpClient(string $encodedContent): HttpClientInterface
    {
        $callback = static function ($method, $url, $options) use ($encodedContent) {
            if (Request::METHOD_GET === $method) {
                switch ($url) {
                    case 'https://example.com/non-encrypted.txt':
                    case 'https://example.com/encrypted.txt':
                        return new MockResponse($encodedContent, ['http_code' => 200]);

                    case 'https://example.com/error_during_http_request.txt':
                        return new TransportException('error_during_http_request.txt');

                    case 'https://example.com/invalid_statuscode.txt':
                        return new MockResponse($encodedContent, ['http_code' => 404]);
                }
            }

            return new MockResponse('Not found');
        };

        return new MockHttpClient($callback);
    }

    private function getSubject(StoredObject $storedObject, string $encodedContent): StoredObjectManagerInterface
    {
        return new StoredObjectManager(
            $this->getHttpClient($encodedContent),
            $this->getTempUrlGenerator($storedObject)
        );
    }

    private function getTempUrlGenerator(StoredObject $storedObject): TempUrlGeneratorInterface
    {
        $response = new SignedUrl(
            'PUT',
            'https://example.com/'.$storedObject->getFilename(),
            new \DateTimeImmutable('1 hours'),
            $storedObject->getFilename()
        );

        $tempUrlGenerator = $this->createMock(TempUrlGeneratorInterface::class);

        $tempUrlGenerator
            ->expects($this->atLeastOnce())
            ->method('generate')
            ->with($this->logicalOr($this->identicalTo('GET'), $this->identicalTo('PUT')), $this->isType('string'))
            ->willReturnCallback(fn (string $method, string $objectName) => new SignedUrl(
                $method,
                'https://example.com/'.$objectName,
                new \DateTimeImmutable('1 hours'),
                $objectName
            ));

        return $tempUrlGenerator;
    }
}
