<?php

declare(strict_types=1);

namespace App\Tests\Unit\Shared\Infrastructure\Audit;

use App\Shared\Domain\Clock;
use App\Shared\Domain\CorrelationId;
use App\Shared\Domain\Tenant\TenantId;
use App\Shared\Infrastructure\Audit\AuditLogger;
use App\Shared\Infrastructure\Audit\CorrelationIdHolder;
use App\Shared\Infrastructure\Tenant\TenantConfig;
use App\Shared\Infrastructure\Tenant\TenantContext;
use App\Shared\Infrastructure\Tenant\TenantId as InfrastructureTenantId;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Ramsey\Uuid\Uuid;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;

/**
 * @see Story 1.7 - T2: AuditLogger Service
 */
final class AuditLoggerTest extends TestCase
{
    private Connection&MockObject $connection;
    private TenantContext $tenantContext;
    private TokenStorageInterface&MockObject $tokenStorage;
    private RequestStack $requestStack;
    private Clock&MockObject $clock;
    private AuditLogger $auditLogger;

    protected function setUp(): void
    {
        $this->connection = $this->createMock(Connection::class);
        $this->tenantContext = new TenantContext();
        $this->tokenStorage = $this->createMock(TokenStorageInterface::class);
        $this->requestStack = new RequestStack();
        $this->clock = $this->createMock(Clock::class);

        $this->auditLogger = new AuditLogger(
            $this->connection,
            $this->tenantContext,
            $this->tokenStorage,
            $this->requestStack,
            $this->clock,
            'test-secret',
        );

        CorrelationIdHolder::clear();
    }

    protected function tearDown(): void
    {
        CorrelationIdHolder::clear();
        $this->tenantContext->clear();
    }

    public function testLogAuthenticationInsertsIntoDatabase(): void
    {
        $now = new DateTimeImmutable('2026-02-03 10:30:00');
        $this->clock->method('now')->willReturn($now);
        $this->tokenStorage->method('getToken')->willReturn(null);

        $userId = Uuid::uuid4();

        $this->connection->expects($this->once())
            ->method('insert')
            ->with(
                'audit_log',
                $this->callback(static function (array $data) use ($userId): bool {
                    return $data['aggregate_type'] === 'User'
                        && $data['aggregate_id'] === $userId->toString()
                        && $data['event_type'] === 'TestEvent'
                        && $data['payload']['test_key'] === 'test_value';
                }),
                $this->anything(),
            );

        $this->auditLogger->logAuthentication('TestEvent', $userId, ['test_key' => 'test_value']);
    }

    public function testLogAuthenticationWithNullUserIdSetsNullAggregateId(): void
    {
        $now = new DateTimeImmutable('2026-02-03 10:30:00');
        $this->clock->method('now')->willReturn($now);
        $this->tokenStorage->method('getToken')->willReturn(null);

        $this->connection->expects($this->once())
            ->method('insert')
            ->with(
                'audit_log',
                $this->callback(static fn (array $data): bool => $data['aggregate_id'] === null),
                $this->anything(),
            );

        $this->auditLogger->logAuthentication('FailedLogin', null, []);
    }

    public function testLogDataChangeIncludesOldAndNewValues(): void
    {
        $now = new DateTimeImmutable('2026-02-03 10:30:00');
        $this->clock->method('now')->willReturn($now);
        $this->tokenStorage->method('getToken')->willReturn(null);

        $aggregateId = Uuid::uuid4();

        $this->connection->expects($this->once())
            ->method('insert')
            ->with(
                'audit_log',
                $this->callback(static function (array $data) use ($aggregateId): bool {
                    $payload = $data['payload'];

                    return $data['aggregate_type'] === 'Note'
                        && $data['aggregate_id'] === $aggregateId->toString()
                        && $data['event_type'] === 'NoteModified'
                        && $payload['old_values']['value'] === 12.5
                        && $payload['new_values']['value'] === 14.0
                        && $payload['reason'] === 'Correction';
                }),
                $this->anything(),
            );

        $this->auditLogger->logDataChange(
            'Note',
            $aggregateId,
            'NoteModified',
            ['value' => 12.5],
            ['value' => 14.0],
            'Correction',
        );
    }

    public function testLogExportIncludesExportDetails(): void
    {
        $now = new DateTimeImmutable('2026-02-03 10:30:00');
        $this->clock->method('now')->willReturn($now);
        $this->tokenStorage->method('getToken')->willReturn(null);

        $this->connection->expects($this->once())
            ->method('insert')
            ->with(
                'audit_log',
                $this->callback(static function (array $data): bool {
                    $payload = $data['payload'];

                    return $data['aggregate_type'] === 'Export'
                        && $data['event_type'] === 'ExportGenerated'
                        && $payload['export_type'] === 'CSV'
                        && $payload['record_count'] === 150
                        && $payload['target'] === 'students_list';
                }),
                $this->anything(),
            );

        $this->auditLogger->logExport('CSV', 150, 'students_list');
    }

    public function testLogAccessIncludesResourceAndContext(): void
    {
        $now = new DateTimeImmutable('2026-02-03 10:30:00');
        $this->clock->method('now')->willReturn($now);
        $this->tokenStorage->method('getToken')->willReturn(null);

        $resourceId = Uuid::uuid4();

        $this->connection->expects($this->once())
            ->method('insert')
            ->with(
                'audit_log',
                $this->callback(static function (array $data) use ($resourceId): bool {
                    $payload = $data['payload'];

                    return $data['aggregate_type'] === 'Student'
                        && $data['aggregate_id'] === $resourceId->toString()
                        && $data['event_type'] === 'ResourceAccessed'
                        && $payload['screen'] === 'profile'
                        && $payload['action'] === 'view';
                }),
                $this->anything(),
            );

        $this->auditLogger->logAccess('Student', $resourceId, ['screen' => 'profile', 'action' => 'view']);
    }

    public function testMetadataIncludesTenantIdWhenAvailable(): void
    {
        $now = new DateTimeImmutable('2026-02-03 10:30:00');
        $this->clock->method('now')->willReturn($now);
        $this->tokenStorage->method('getToken')->willReturn(null);

        $tenantId = TenantId::generate();
        $this->setCurrentTenant($tenantId);

        $this->connection->expects($this->once())
            ->method('insert')
            ->with(
                'audit_log',
                $this->callback(static function (array $data) use ($tenantId): bool {
                    $metadata = $data['metadata'];

                    return $metadata['tenant_id'] === (string) $tenantId;
                }),
                $this->anything(),
            );

        $this->auditLogger->logAuthentication('Test', null, []);
    }

    public function testMetadataIncludesCorrelationIdWhenSet(): void
    {
        $now = new DateTimeImmutable('2026-02-03 10:30:00');
        $this->clock->method('now')->willReturn($now);
        $this->tokenStorage->method('getToken')->willReturn(null);

        $correlationId = CorrelationId::generate();
        CorrelationIdHolder::set($correlationId);

        $this->connection->expects($this->once())
            ->method('insert')
            ->with(
                'audit_log',
                $this->callback(static function (array $data) use ($correlationId): bool {
                    $metadata = $data['metadata'];

                    return $metadata['correlation_id'] === $correlationId->value();
                }),
                $this->anything(),
            );

        $this->auditLogger->logAuthentication('Test', null, []);
    }

    public function testMetadataIncludesHashedIpFromRequest(): void
    {
        $now = new DateTimeImmutable('2026-02-03 10:30:00');
        $this->clock->method('now')->willReturn($now);
        $this->tokenStorage->method('getToken')->willReturn(null);

        $request = Request::create('/test');
        $request->server->set('REMOTE_ADDR', '192.168.1.100');
        $this->requestStack->push($request);

        $expectedIpHash = hash('sha256', '192.168.1.100test-secret');

        $this->connection->expects($this->once())
            ->method('insert')
            ->with(
                'audit_log',
                $this->callback(static function (array $data) use ($expectedIpHash): bool {
                    $metadata = $data['metadata'];

                    return $metadata['ip_hash'] === $expectedIpHash;
                }),
                $this->anything(),
            );

        $this->auditLogger->logAuthentication('Test', null, []);
    }

    public function testMetadataIncludesUserAgentHash(): void
    {
        $now = new DateTimeImmutable('2026-02-03 10:30:00');
        $this->clock->method('now')->willReturn($now);
        $this->tokenStorage->method('getToken')->willReturn(null);

        $request = Request::create('/test');
        $request->headers->set('User-Agent', 'Mozilla/5.0 TestBrowser');
        $this->requestStack->push($request);

        $expectedUaHash = hash('sha256', 'Mozilla/5.0 TestBrowser');

        $this->connection->expects($this->once())
            ->method('insert')
            ->with(
                'audit_log',
                $this->callback(static function (array $data) use ($expectedUaHash): bool {
                    $metadata = $data['metadata'];

                    return $metadata['user_agent_hash'] === $expectedUaHash;
                }),
                $this->anything(),
            );

        $this->auditLogger->logAuthentication('Test', null, []);
    }

    public function testLogAuthenticationWithTenantIdOverride(): void
    {
        $now = new DateTimeImmutable('2026-02-03 10:30:00');
        $this->clock->method('now')->willReturn($now);
        $this->tokenStorage->method('getToken')->willReturn(null);

        $overrideTenantId = 'override-tenant-uuid-1234';

        $this->connection->expects($this->once())
            ->method('insert')
            ->with(
                'audit_log',
                $this->callback(static function (array $data) use ($overrideTenantId): bool {
                    $metadata = $data['metadata'];

                    return $metadata['tenant_id'] === $overrideTenantId;
                }),
                $this->anything(),
            );

        // No TenantContext set, but override should be used
        $this->auditLogger->logAuthentication('Test', null, [], $overrideTenantId);
    }

    public function testTenantIdOverrideTakesPrecedenceOverContext(): void
    {
        $now = new DateTimeImmutable('2026-02-03 10:30:00');
        $this->clock->method('now')->willReturn($now);
        $this->tokenStorage->method('getToken')->willReturn(null);

        // Set a tenant in context
        $contextTenantId = TenantId::generate();
        $this->setCurrentTenant($contextTenantId);

        // But use a different override
        $overrideTenantId = 'override-tenant-uuid-5678';

        $this->connection->expects($this->once())
            ->method('insert')
            ->with(
                'audit_log',
                $this->callback(static function (array $data) use ($overrideTenantId): bool {
                    $metadata = $data['metadata'];

                    // Override should take precedence over context
                    return $metadata['tenant_id'] === $overrideTenantId;
                }),
                $this->anything(),
            );

        $this->auditLogger->logAuthentication('Test', null, [], $overrideTenantId);
    }

    private function setCurrentTenant(TenantId $tenantId): void
    {
        $config = new TenantConfig(
            tenantId: InfrastructureTenantId::fromString((string) $tenantId),
            subdomain: 'test-tenant',
            databaseUrl: 'postgresql://user:pass@localhost:5432/classeo_test',
        );
        $this->tenantContext->setCurrentTenant($config);
    }
}
