<?php

namespace MintyPHP\Tests\Service\Auth;

use MintyPHP\Service\Auth\LdapAuthService;
use MintyPHP\Service\Auth\LdapConnectionGateway;
use PHPUnit\Framework\TestCase;

class LdapAuthServiceTest extends TestCase
{
    private function newService(?LdapConnectionGateway $ldap = null): LdapAuthService
    {
        return new LdapAuthService(
            $ldap ?? $this->createMock(LdapConnectionGateway::class)
        );
    }

    private function baseConfig(): array
    {
        return [
            'host' => 'ldap.example.com',
            'port' => 389,
            'encryption_mode' => 'none',
            'verify_tls_certificate' => false,
            'base_dn' => 'DC=example,DC=com',
            'bind_dn' => 'CN=svc,DC=example,DC=com',
            'bind_password' => 'secret',
            'user_search_filter' => '(&(objectClass=user)(sAMAccountName=%s))',
            'user_search_scope' => 'sub',
            'bind_method' => 'search_then_bind',
            'bind_username_format' => '',
            'unique_id_attribute' => 'objectGUID',
            'attribute_map' => '{"first_name":"givenName","last_name":"sn","email":"mail"}',
            'network_timeout' => 5,
        ];
    }

    public function testAuthenticateReturnsCredentialsEmptyForEmptyUsername(): void
    {
        $service = $this->newService();
        $result = $service->authenticate($this->baseConfig(), '', 'password');
        $this->assertFalse($result['ok']);
        $this->assertSame('credentials_empty', $result['error']);
    }

    public function testAuthenticateReturnsCredentialsEmptyForEmptyPassword(): void
    {
        $service = $this->newService();
        $result = $service->authenticate($this->baseConfig(), 'user', '');
        $this->assertFalse($result['ok']);
        $this->assertSame('credentials_empty', $result['error']);
    }

    public function testAuthenticateReturnsConnectionFailedWhenConnectFails(): void
    {
        $ldap = $this->createMock(LdapConnectionGateway::class);
        $ldap->method('connect')->willReturn(false);

        $service = $this->newService($ldap);
        $result = $service->authenticate($this->baseConfig(), 'user', 'pass');
        $this->assertFalse($result['ok']);
        $this->assertSame('connection_failed', $result['error']);
    }

    public function testAuthenticateReturnsConnectionFailedForEmptyHost(): void
    {
        $config = $this->baseConfig();
        $config['host'] = '';

        $service = $this->newService();
        $result = $service->authenticate($config, 'user', 'pass');
        $this->assertFalse($result['ok']);
        $this->assertSame('connection_failed', $result['error']);
    }

    public function testAuthenticateSearchThenBindServiceBindFails(): void
    {
        $conn = $this->createMockConnection();
        $ldap = $this->createMock(LdapConnectionGateway::class);
        $ldap->method('connect')->willReturn($conn);
        $ldap->method('setOption')->willReturn(true);
        $ldap->method('bind')->willReturn(false);
        $ldap->method('unbind')->willReturn(true);

        $service = $this->newService($ldap);
        $result = $service->authenticate($this->baseConfig(), 'user', 'pass');
        $this->assertFalse($result['ok']);
        $this->assertSame('service_bind_failed', $result['error']);
    }

    public function testAuthenticateSearchThenBindUserNotFound(): void
    {
        $conn = $this->createMockConnection();
        $ldap = $this->createMock(LdapConnectionGateway::class);
        $ldap->method('connect')->willReturn($conn);
        $ldap->method('setOption')->willReturn(true);
        $ldap->method('bind')->willReturn(true);
        $ldap->method('search')->willReturn(false);
        $ldap->method('unbind')->willReturn(true);

        $service = $this->newService($ldap);
        $result = $service->authenticate($this->baseConfig(), 'user', 'pass');
        $this->assertFalse($result['ok']);
        $this->assertSame('user_not_found', $result['error']);
    }

    public function testAuthenticateSearchThenBindInvalidPassword(): void
    {
        $conn = $this->createMockConnection();
        $searchResult = $this->createMockSearchResult();
        $ldap = $this->createMock(LdapConnectionGateway::class);
        $ldap->method('connect')->willReturn($conn);
        $ldap->method('setOption')->willReturn(true);
        $ldap->method('escape')->willReturnCallback(fn ($v) => $v);
        $ldap->method('search')->willReturn($searchResult);
        $ldap->method('getEntries')->willReturn([
            'count' => 1,
            0 => [
                'dn' => 'CN=Test User,DC=example,DC=com',
                'givenname' => ['count' => 1, 0 => 'Test'],
                'sn' => ['count' => 1, 0 => 'User'],
                'mail' => ['count' => 1, 0 => 'test@example.com'],
            ],
        ]);
        $ldap->method('unbind')->willReturn(true);

        $bindCallCount = 0;
        $ldap->method('bind')->willReturnCallback(function () use (&$bindCallCount) {
            $bindCallCount++;
            return $bindCallCount <= 1;
        });

        $service = $this->newService($ldap);
        $result = $service->authenticate($this->baseConfig(), 'testuser', 'wrongpass');
        $this->assertFalse($result['ok']);
        $this->assertSame('invalid_credentials', $result['error']);
    }

    public function testAuthenticateSearchThenBindSuccess(): void
    {
        $conn = $this->createMockConnection();
        $searchResult = $this->createMockSearchResult();
        $ldap = $this->createMock(LdapConnectionGateway::class);
        $ldap->method('connect')->willReturn($conn);
        $ldap->method('setOption')->willReturn(true);
        $ldap->method('bind')->willReturn(true);
        $ldap->method('escape')->willReturnCallback(fn ($v) => $v);
        $ldap->method('search')->willReturn($searchResult);
        $ldap->method('getEntries')->willReturn([
            'count' => 1,
            0 => [
                'dn' => 'CN=Test User,DC=example,DC=com',
                'givenname' => ['count' => 1, 0 => 'Test'],
                'sn' => ['count' => 1, 0 => 'User'],
                'mail' => ['count' => 1, 0 => 'test@example.com'],
                'objectguid' => ['count' => 1, 0 => 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'],
            ],
        ]);
        $ldap->method('unbind')->willReturn(true);

        $service = $this->newService($ldap);
        $result = $service->authenticate($this->baseConfig(), 'testuser', 'correctpass');
        $this->assertTrue($result['ok']);
        $this->assertSame('CN=Test User,DC=example,DC=com', $result['dn']);
        $this->assertSame('Test', $result['attributes']['first_name']);
        $this->assertSame('User', $result['attributes']['last_name']);
        $this->assertSame('test@example.com', $result['attributes']['email']);
    }

    public function testAuthenticateDirectBindSuccess(): void
    {
        $config = $this->baseConfig();
        $config['bind_method'] = 'direct_bind';
        $config['bind_username_format'] = '%s@example.com';

        $conn = $this->createMockConnection();
        $searchResult = $this->createMockSearchResult();
        $ldap = $this->createMock(LdapConnectionGateway::class);
        $ldap->method('connect')->willReturn($conn);
        $ldap->method('setOption')->willReturn(true);
        $ldap->method('bind')->willReturn(true);
        $ldap->method('escape')->willReturnCallback(fn ($v) => $v);
        $ldap->method('search')->willReturn($searchResult);
        $ldap->method('getEntries')->willReturn([
            'count' => 1,
            0 => [
                'dn' => 'CN=Test User,DC=example,DC=com',
                'givenname' => ['count' => 1, 0 => 'Direct'],
                'sn' => ['count' => 1, 0 => 'User'],
                'mail' => ['count' => 1, 0 => 'direct@example.com'],
            ],
        ]);
        $ldap->method('unbind')->willReturn(true);

        $service = $this->newService($ldap);
        $result = $service->authenticate($config, 'testuser', 'pass');
        $this->assertTrue($result['ok']);
        $this->assertSame('Direct', $result['attributes']['first_name']);
    }

    public function testLdapInjectionIsEscaped(): void
    {
        $ldap = $this->createMock(LdapConnectionGateway::class);
        $escapedValue = '';
        $ldap->method('escape')->willReturnCallback(function (string $v) use (&$escapedValue) {
            $escapedValue = $v;
            return 'escaped_' . $v;
        });
        $conn = $this->createMockConnection();
        $ldap->method('connect')->willReturn($conn);
        $ldap->method('setOption')->willReturn(true);
        $ldap->method('bind')->willReturn(true);
        $ldap->method('search')->willReturn(false);
        $ldap->method('unbind')->willReturn(true);

        $service = $this->newService($ldap);
        $result = $service->authenticate($this->baseConfig(), 'user*)(|(uid=*)', 'pass');

        $this->assertSame('user*)(|(uid=*)', $escapedValue);
        $this->assertFalse($result['ok']);
        $this->assertSame('user_not_found', $result['error']);
    }

    public function testBinaryGuidToUuid(): void
    {
        $binary = hex2bin('a1a2a3a4b1b2c1c2d1d2e1e2e3e4e5e6');
        $uuid = LdapAuthService::binaryGuidToUuid($binary);
        $this->assertMatchesRegularExpression('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/', $uuid);
    }

    public function testBinaryGuidToUuidNon16Bytes(): void
    {
        $result = LdapAuthService::binaryGuidToUuid('short');
        $this->assertSame(bin2hex('short'), $result);
    }

    public function testTestConnectionSuccess(): void
    {
        $conn = $this->createMockConnection();
        $ldap = $this->createMock(LdapConnectionGateway::class);
        $ldap->method('connect')->willReturn($conn);
        $ldap->method('setOption')->willReturn(true);
        $ldap->method('bind')->willReturn(true);
        $ldap->method('unbind')->willReturn(true);

        $service = $this->newService($ldap);
        $result = $service->testConnection($this->baseConfig());
        $this->assertTrue($result['ok']);
    }

    public function testTestConnectionBindFailure(): void
    {
        $conn = $this->createMockConnection();
        $ldap = $this->createMock(LdapConnectionGateway::class);
        $ldap->method('connect')->willReturn($conn);
        $ldap->method('setOption')->willReturn(true);
        $ldap->method('bind')->willReturn(false);
        $ldap->method('error')->willReturn('Invalid credentials');
        $ldap->method('unbind')->willReturn(true);

        $service = $this->newService($ldap);
        $result = $service->testConnection($this->baseConfig());
        $this->assertFalse($result['ok']);
        $this->assertStringContainsString('Service account bind failed', $result['error']);
    }

    public function testStarttlsFailureReturnsConnectionFailed(): void
    {
        $config = $this->baseConfig();
        $config['encryption_mode'] = 'starttls';

        $conn = $this->createMockConnection();
        $ldap = $this->createMock(LdapConnectionGateway::class);
        $ldap->method('connect')->willReturn($conn);
        $ldap->method('setOption')->willReturn(true);
        $ldap->method('startTls')->willReturn(false);

        $service = $this->newService($ldap);
        $result = $service->authenticate($config, 'user', 'pass');
        $this->assertFalse($result['ok']);
        $this->assertSame('connection_failed', $result['error']);
    }

    private function createMockConnection(): \LDAP\Connection
    {
        // We need a real LDAP\Connection object for type hints.
        // Since we can't create one without ldap_connect, use reflection.
        // For tests where ldap extension may not be available, skip.
        if (!extension_loaded('ldap')) {
            $this->markTestSkipped('LDAP extension not available');
        }

        // ldap_connect with a URI doesn't actually connect in PHP 8.1+
        $conn = ldap_connect('ldap://127.0.0.1:1');
        if ($conn === false) {
            $this->markTestSkipped('Could not create LDAP connection resource');
        }

        return $conn;
    }

    private function createMockSearchResult(): object
    {
        // The service treats the search handle as opaque and forwards it to
        // gateway->getEntries(); in tests the gateway is mocked, so any object
        // is sufficient and avoids doubling final internal LDAP classes.
        return new \stdClass();
    }
}
