<?php

namespace App\Tests\Controller;

use App\Controller\AnalyticsController;
use App\Entity\AnalyticsUniqId;
use App\Message\AnalyticsMessage;
use App\Service\AnalyticsCryptoService;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;

class AnalyticsControllerTest extends TestCase
{
    private const ANALYTICS_SECRET = 'test_analytics_secret';

    private AnalyticsCryptoService $crypto;
    private string $validToken;
    private AnalyticsController $controller;

    protected function setUp(): void
    {
        $this->crypto = new AnalyticsCryptoService(self::ANALYTICS_SECRET);
        $this->validToken = substr(hash('sha256', self::ANALYTICS_SECRET.'_endpoint'), 0, 8);
        $this->controller = new AnalyticsController();
    }

    private function createRequest(string $body, string $userAgent = 'TestAgent/1.0'): Request
    {
        $request = Request::create('/t/'.$this->validToken, 'POST', [], [], [], [
            'HTTP_USER_AGENT' => $userAgent,
        ], $body);
        $request->headers->set('Content-Type', 'application/json');

        return $request;
    }

    private function createMockEm(?AnalyticsUniqId $persisted = null): EntityManagerInterface
    {
        $em = $this->createMock(EntityManagerInterface::class);
        if ($persisted) {
            $em->expects(self::once())->method('persist')->with(self::isInstanceOf(AnalyticsUniqId::class));
            $em->expects(self::once())->method('flush');
        }

        return $em;
    }

    private function createMockBus(): MessageBusInterface
    {
        $bus = $this->createMock(MessageBusInterface::class);
        $bus->method('dispatch')->willReturnCallback(fn ($msg) => new Envelope($msg));

        return $bus;
    }

    public function testInvalidTokenReturns404(): void
    {
        $request = $this->createRequest(json_encode(['d' => 'anything']));
        $em = $this->createMock(EntityManagerInterface::class);
        $bus = $this->createMock(MessageBusInterface::class);

        $response = $this->controller->track('badtoken', self::ANALYTICS_SECRET, $request, $this->crypto, $em, $bus);

        self::assertSame(404, $response->getStatusCode());
    }

    public function testMissingBodyReturns400(): void
    {
        $request = $this->createRequest('');
        $em = $this->createMock(EntityManagerInterface::class);
        $bus = $this->createMock(MessageBusInterface::class);

        $response = $this->controller->track($this->validToken, self::ANALYTICS_SECRET, $request, $this->crypto, $em, $bus);

        self::assertSame(400, $response->getStatusCode());
    }

    public function testMissingDFieldReturns400(): void
    {
        $request = $this->createRequest(json_encode(['foo' => 'bar']));
        $em = $this->createMock(EntityManagerInterface::class);
        $bus = $this->createMock(MessageBusInterface::class);

        $response = $this->controller->track($this->validToken, self::ANALYTICS_SECRET, $request, $this->crypto, $em, $bus);

        self::assertSame(400, $response->getStatusCode());
    }

    public function testInvalidJsonBodyReturns400(): void
    {
        $request = $this->createRequest('not-json');
        $em = $this->createMock(EntityManagerInterface::class);
        $bus = $this->createMock(MessageBusInterface::class);

        $response = $this->controller->track($this->validToken, self::ANALYTICS_SECRET, $request, $this->crypto, $em, $bus);

        self::assertSame(400, $response->getStatusCode());
    }

    public function testInvalidEncryptedDataReturns403(): void
    {
        $request = $this->createRequest(json_encode(['d' => 'invalid_encrypted_payload']));
        $em = $this->createMock(EntityManagerInterface::class);
        $bus = $this->createMock(MessageBusInterface::class);

        $response = $this->controller->track($this->validToken, self::ANALYTICS_SECRET, $request, $this->crypto, $em, $bus);

        self::assertSame(403, $response->getStatusCode());
    }

    public function testNewVisitorWithoutUidCreatesVisitorAndReturnsUid(): void
    {
        $encrypted = $this->crypto->encrypt([
            'sw' => 1920,
            'sh' => 1080,
            'l' => 'fr-FR',
        ]);
        $request = $this->createRequest(
            json_encode(['d' => $encrypted]),
            'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
        );

        $persistedVisitor = null;
        $em = $this->createMock(EntityManagerInterface::class);
        $em->expects(self::once())->method('persist')->with(self::callback(
            function (AnalyticsUniqId $v) use (&$persistedVisitor) {
                $persistedVisitor = $v;

                return true;
            }
        ));
        $em->expects(self::once())->method('flush');
        $bus = $this->createMock(MessageBusInterface::class);

        $response = $this->controller->track($this->validToken, self::ANALYTICS_SECRET, $request, $this->crypto, $em, $bus);

        self::assertSame(200, $response->getStatusCode());
        self::assertInstanceOf(JsonResponse::class, $response);

        $body = json_decode($response->getContent(), true);
        self::assertArrayHasKey('d', $body);

        $decrypted = $this->crypto->decrypt($body['d']);
        self::assertArrayHasKey('uid', $decrypted);
        self::assertArrayHasKey('h', $decrypted);

        self::assertNotNull($persistedVisitor);
        self::assertSame(1920, $persistedVisitor->getScreenWidth());
        self::assertSame(1080, $persistedVisitor->getScreenHeight());
        self::assertSame('fr-FR', $persistedVisitor->getLanguage());
        self::assertSame('desktop', $persistedVisitor->getDeviceType());
        self::assertSame('Windows', $persistedVisitor->getOs());
        self::assertSame('Chrome', $persistedVisitor->getBrowser());
    }

    public function testNewVisitorWithoutOptionalFieldsCreatesVisitor(): void
    {
        $encrypted = $this->crypto->encrypt([]);
        $request = $this->createRequest(json_encode(['d' => $encrypted]));

        $persistedVisitor = null;
        $em = $this->createMock(EntityManagerInterface::class);
        $em->expects(self::once())->method('persist')->with(self::callback(
            function (AnalyticsUniqId $v) use (&$persistedVisitor) {
                $persistedVisitor = $v;

                return true;
            }
        ));
        $em->expects(self::once())->method('flush');
        $bus = $this->createMock(MessageBusInterface::class);

        $response = $this->controller->track($this->validToken, self::ANALYTICS_SECRET, $request, $this->crypto, $em, $bus);

        self::assertSame(200, $response->getStatusCode());
        self::assertNotNull($persistedVisitor);
        self::assertNull($persistedVisitor->getScreenWidth());
        self::assertNull($persistedVisitor->getScreenHeight());
        self::assertNull($persistedVisitor->getLanguage());
    }

    public function testNewVisitorMobileUserAgent(): void
    {
        $encrypted = $this->crypto->encrypt([]);
        $request = $this->createRequest(
            json_encode(['d' => $encrypted]),
            'Mozilla/5.0 (Linux; Android 13; SM-G991B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36'
        );

        $persistedVisitor = null;
        $em = $this->createMock(EntityManagerInterface::class);
        $em->expects(self::once())->method('persist')->with(self::callback(
            function (AnalyticsUniqId $v) use (&$persistedVisitor) {
                $persistedVisitor = $v;

                return true;
            }
        ));
        $em->expects(self::once())->method('flush');
        $bus = $this->createMock(MessageBusInterface::class);

        $response = $this->controller->track($this->validToken, self::ANALYTICS_SECRET, $request, $this->crypto, $em, $bus);

        self::assertSame(200, $response->getStatusCode());
        self::assertNotNull($persistedVisitor);
        self::assertSame('mobile', $persistedVisitor->getDeviceType());
        self::assertSame('Android', $persistedVisitor->getOs());
        self::assertSame('Chrome', $persistedVisitor->getBrowser());
    }

    public function testNewVisitorTabletUserAgent(): void
    {
        $encrypted = $this->crypto->encrypt([]);
        $request = $this->createRequest(
            json_encode(['d' => $encrypted]),
            'Mozilla/5.0 (iPad; CPU OS 16_0 like Mac OS X) AppleWebKit/605.1.15'
        );

        $persistedVisitor = null;
        $em = $this->createMock(EntityManagerInterface::class);
        $em->expects(self::once())->method('persist')->with(self::callback(
            function (AnalyticsUniqId $v) use (&$persistedVisitor) {
                $persistedVisitor = $v;

                return true;
            }
        ));
        $em->expects(self::once())->method('flush');
        $bus = $this->createMock(MessageBusInterface::class);

        $response = $this->controller->track($this->validToken, self::ANALYTICS_SECRET, $request, $this->crypto, $em, $bus);

        self::assertSame(200, $response->getStatusCode());
        self::assertNotNull($persistedVisitor);
        self::assertSame('tablet', $persistedVisitor->getDeviceType());
    }

    public function testPageViewWithValidHashDispatches204(): void
    {
        $uid = 'test-uid-123';
        $hash = $this->crypto->generateVisitorHash($uid);
        $encrypted = $this->crypto->encrypt([
            'uid' => $uid,
            'h' => $hash,
            'u' => '/test-page',
            't' => 'Test Page',
            'r' => 'https://google.com',
        ]);
        $request = $this->createRequest(json_encode(['d' => $encrypted]));
        $em = $this->createMock(EntityManagerInterface::class);

        $bus = $this->createMock(MessageBusInterface::class);
        $bus->expects(self::once())->method('dispatch')->with(self::callback(
            fn (AnalyticsMessage $msg) => $msg->uid === $uid
                && 'page_view' === $msg->action
                && '/test-page' === $msg->payload['url']
                && 'Test Page' === $msg->payload['title']
                && 'https://google.com' === $msg->payload['referrer']
        ))->willReturnCallback(fn ($msg) => new Envelope($msg));

        $response = $this->controller->track($this->validToken, self::ANALYTICS_SECRET, $request, $this->crypto, $em, $bus);

        self::assertSame(204, $response->getStatusCode());
    }

    public function testPageViewWithDefaultValues(): void
    {
        $uid = 'test-uid-456';
        $hash = $this->crypto->generateVisitorHash($uid);
        $encrypted = $this->crypto->encrypt([
            'uid' => $uid,
            'h' => $hash,
        ]);
        $request = $this->createRequest(json_encode(['d' => $encrypted]));
        $em = $this->createMock(EntityManagerInterface::class);

        $bus = $this->createMock(MessageBusInterface::class);
        $bus->expects(self::once())->method('dispatch')->with(self::callback(
            fn (AnalyticsMessage $msg) => '/' === $msg->payload['url']
                && null === $msg->payload['title']
                && null === $msg->payload['referrer']
        ))->willReturnCallback(fn ($msg) => new Envelope($msg));

        $response = $this->controller->track($this->validToken, self::ANALYTICS_SECRET, $request, $this->crypto, $em, $bus);

        self::assertSame(204, $response->getStatusCode());
    }

    public function testPageViewWithInvalidHashReturns403(): void
    {
        $encrypted = $this->crypto->encrypt([
            'uid' => 'some-uid',
            'h' => 'invalid_hash',
            'u' => '/test',
        ]);
        $request = $this->createRequest(json_encode(['d' => $encrypted]));
        $em = $this->createMock(EntityManagerInterface::class);
        $bus = $this->createMock(MessageBusInterface::class);
        $bus->expects(self::never())->method('dispatch');

        $response = $this->controller->track($this->validToken, self::ANALYTICS_SECRET, $request, $this->crypto, $em, $bus);

        self::assertSame(403, $response->getStatusCode());
    }

    public function testPageViewWithMissingHashReturns403(): void
    {
        $encrypted = $this->crypto->encrypt([
            'uid' => 'some-uid',
            'u' => '/test',
        ]);
        $request = $this->createRequest(json_encode(['d' => $encrypted]));
        $em = $this->createMock(EntityManagerInterface::class);
        $bus = $this->createMock(MessageBusInterface::class);
        $bus->expects(self::never())->method('dispatch');

        $response = $this->controller->track($this->validToken, self::ANALYTICS_SECRET, $request, $this->crypto, $em, $bus);

        self::assertSame(403, $response->getStatusCode());
    }

    public function testSetUserWithValidHashDispatches204(): void
    {
        $uid = 'test-uid-789';
        $hash = $this->crypto->generateVisitorHash($uid);
        $encrypted = $this->crypto->encrypt([
            'uid' => $uid,
            'h' => $hash,
            'setUser' => 42,
        ]);
        $request = $this->createRequest(json_encode(['d' => $encrypted]));
        $em = $this->createMock(EntityManagerInterface::class);

        $bus = $this->createMock(MessageBusInterface::class);
        $bus->expects(self::once())->method('dispatch')->with(self::callback(
            fn (AnalyticsMessage $msg) => $msg->uid === $uid
                && 'set_user' === $msg->action
                && 42 === $msg->payload['userId']
        ))->willReturnCallback(fn ($msg) => new Envelope($msg));

        $response = $this->controller->track($this->validToken, self::ANALYTICS_SECRET, $request, $this->crypto, $em, $bus);

        self::assertSame(204, $response->getStatusCode());
    }

    public function testVisitorUidFormatIsValid(): void
    {
        $encrypted = $this->crypto->encrypt([]);
        $request = $this->createRequest(json_encode(['d' => $encrypted]));

        $persistedVisitor = null;
        $em = $this->createMock(EntityManagerInterface::class);
        $em->expects(self::once())->method('persist')->with(self::callback(
            function (AnalyticsUniqId $v) use (&$persistedVisitor) {
                $persistedVisitor = $v;

                return true;
            }
        ));
        $em->expects(self::once())->method('flush');
        $bus = $this->createMock(MessageBusInterface::class);

        $this->controller->track($this->validToken, self::ANALYTICS_SECRET, $request, $this->crypto, $em, $bus);

        self::assertNotNull($persistedVisitor);
        self::assertMatchesRegularExpression(
            '/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/',
            $persistedVisitor->getUid()
        );
    }

    public function testVisitorIpHashIsSet(): void
    {
        $encrypted = $this->crypto->encrypt([]);
        $request = $this->createRequest(json_encode(['d' => $encrypted]));

        $persistedVisitor = null;
        $em = $this->createMock(EntityManagerInterface::class);
        $em->expects(self::once())->method('persist')->with(self::callback(
            function (AnalyticsUniqId $v) use (&$persistedVisitor) {
                $persistedVisitor = $v;

                return true;
            }
        ));
        $em->expects(self::once())->method('flush');
        $bus = $this->createMock(MessageBusInterface::class);

        $this->controller->track($this->validToken, self::ANALYTICS_SECRET, $request, $this->crypto, $em, $bus);

        self::assertNotNull($persistedVisitor);
        self::assertSame(64, \strlen($persistedVisitor->getIpHash()));
    }

    public function testVisitorUserAgentIsTruncatedTo512(): void
    {
        $longUa = str_repeat('A', 600);
        $encrypted = $this->crypto->encrypt([]);
        $request = $this->createRequest(json_encode(['d' => $encrypted]), $longUa);

        $persistedVisitor = null;
        $em = $this->createMock(EntityManagerInterface::class);
        $em->expects(self::once())->method('persist')->with(self::callback(
            function (AnalyticsUniqId $v) use (&$persistedVisitor) {
                $persistedVisitor = $v;

                return true;
            }
        ));
        $em->expects(self::once())->method('flush');
        $bus = $this->createMock(MessageBusInterface::class);

        $this->controller->track($this->validToken, self::ANALYTICS_SECRET, $request, $this->crypto, $em, $bus);

        self::assertNotNull($persistedVisitor);
        self::assertSame(512, \strlen($persistedVisitor->getUserAgent()));
    }

    public function testVisitorLanguageIsTruncatedTo10(): void
    {
        $encrypted = $this->crypto->encrypt(['l' => 'fr-FR-extra-long']);
        $request = $this->createRequest(json_encode(['d' => $encrypted]));

        $persistedVisitor = null;
        $em = $this->createMock(EntityManagerInterface::class);
        $em->expects(self::once())->method('persist')->with(self::callback(
            function (AnalyticsUniqId $v) use (&$persistedVisitor) {
                $persistedVisitor = $v;

                return true;
            }
        ));
        $em->expects(self::once())->method('flush');
        $bus = $this->createMock(MessageBusInterface::class);

        $this->controller->track($this->validToken, self::ANALYTICS_SECRET, $request, $this->crypto, $em, $bus);

        self::assertNotNull($persistedVisitor);
        self::assertSame(10, \strlen($persistedVisitor->getLanguage()));
    }

    public function testResponseHashIsVerifiable(): void
    {
        $encrypted = $this->crypto->encrypt([]);
        $request = $this->createRequest(json_encode(['d' => $encrypted]));
        $em = $this->createMockEm(new AnalyticsUniqId());
        $bus = $this->createMock(MessageBusInterface::class);

        $response = $this->controller->track($this->validToken, self::ANALYTICS_SECRET, $request, $this->crypto, $em, $bus);

        $body = json_decode($response->getContent(), true);
        $decrypted = $this->crypto->decrypt($body['d']);

        self::assertTrue($this->crypto->verifyVisitorHash($decrypted['uid'], $decrypted['h']));
    }
}
