<?php

namespace MintyPHP\Tests\Service\Auth;

use MintyPHP\Service\Auth\AuthTenantGateway;
use MintyPHP\Service\Auth\MicrosoftOidcService;
use MintyPHP\Service\Auth\MicrosoftOidcStateStoreService;
use MintyPHP\Service\Auth\OidcHttpGateway;
use MintyPHP\Service\Auth\TenantSsoService;
use PHPUnit\Framework\TestCase;

class MicrosoftOidcServiceTest extends TestCase
{
    public function testStartAuthorizationReturnsTenantNotFoundForInvalidTenant(): void
    {
        $service = $this->newService();

        $result = $service->startAuthorization(['id' => 0], ['authority' => 'https://login.microsoftonline.com/common/v2.0']);

        $this->assertFalse($result['ok']);
        $this->assertSame('tenant_not_found', $result['error']);
    }

    public function testStartAuthorizationReturnsDiscoveryFailedWhenMetadataLookupFails(): void
    {
        $oidcGateway = $this->createMock(OidcHttpGateway::class);
        $oidcGateway->expects($this->once())
            ->method('call')
            ->willReturn(['status' => 500, 'data' => '']);

        $stateStore = $this->createMock(MicrosoftOidcStateStoreService::class);
        $stateStore->expects($this->never())->method('prune');
        $stateStore->expects($this->never())->method('store');

        $service = $this->newService(null, null, $oidcGateway, $stateStore);
        $result = $service->startAuthorization(
            ['id' => 7],
            ['authority' => 'https://login.microsoftonline.com/common/v2.0']
        );

        $this->assertFalse($result['ok']);
        $this->assertSame('oidc_discovery_failed', $result['error']);
    }

    public function testStartAuthorizationReturnsAuthorizeEndpointMissingWhenMetadataIsIncomplete(): void
    {
        $oidcGateway = $this->createMock(OidcHttpGateway::class);
        $oidcGateway->method('call')->willReturn([
            'status' => 200,
            'data' => json_encode(['issuer' => 'https://issuer.example']) ?: '{}',
        ]);

        $storedState = '';
        $storedPayload = [];
        $stateStore = $this->createMock(MicrosoftOidcStateStoreService::class);
        $stateStore->expects($this->once())->method('prune');
        $stateStore->expects($this->once())
            ->method('store')
            ->willReturnCallback(function (string $state, array $payload) use (&$storedState, &$storedPayload): void {
                $storedState = $state;
                $storedPayload = $payload;
            });

        $service = $this->newService(null, null, $oidcGateway, $stateStore);
        $result = $service->startAuthorization(
            ['id' => 7],
            ['authority' => 'https://login.microsoftonline.com/common/v2.0', 'client_id' => 'client-id']
        );

        $this->assertFalse($result['ok']);
        $this->assertSame('authorize_endpoint_missing', $result['error']);
        $this->assertNotSame('', $storedState);
        $this->assertSame(7, (int) ($storedPayload['tenant_id'] ?? 0));
    }

    public function testStartAuthorizationBuildsPkceAuthorizationUrlAndStoresState(): void
    {
        $tenantSsoService = $this->createMock(TenantSsoService::class);
        $tenantSsoService->method('normalizeProfileSyncFields')->willReturn(['avatar']);
        $tenantSsoService->method('profileSyncFieldsRequireGraph')->willReturn(true);

        $oidcGateway = $this->createMock(OidcHttpGateway::class);
        $oidcGateway->expects($this->once())
            ->method('call')
            ->willReturn([
                'status' => 200,
                'data' => json_encode([
                    'authorization_endpoint' => 'https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize',
                ]) ?: '{}',
            ]);

        $storedState = '';
        $storedPayload = [];
        $stateStore = $this->createMock(MicrosoftOidcStateStoreService::class);
        $stateStore->expects($this->once())->method('prune');
        $stateStore->expects($this->once())
            ->method('store')
            ->willReturnCallback(function (string $state, array $payload) use (&$storedState, &$storedPayload): void {
                $storedState = $state;
                $storedPayload = $payload;
            });

        $service = $this->newService($tenantSsoService, null, $oidcGateway, $stateStore);
        $result = $service->startAuthorization(
            ['id' => 9],
            [
                'authority' => 'https://login.microsoftonline.com/tenant/v2.0',
                'client_id' => 'client-id',
                'sync_profile_on_login' => 1,
                'sync_profile_fields' => ['avatar'],
            ]
        );

        $this->assertTrue($result['ok']);
        $this->assertNotSame('', $storedState);
        $this->assertSame(9, (int) ($storedPayload['tenant_id'] ?? 0));
        $this->assertNotSame('', (string) ($storedPayload['redirect_uri'] ?? ''));

        $query = $this->queryFromUrl((string) ($result['url'] ?? ''));
        $this->assertSame('client-id', (string) ($query['client_id'] ?? ''));
        $this->assertSame('code', (string) ($query['response_type'] ?? ''));
        $this->assertSame('query', (string) ($query['response_mode'] ?? ''));
        $this->assertSame('S256', (string) ($query['code_challenge_method'] ?? ''));
        $this->assertSame($storedState, (string) ($query['state'] ?? ''));
        $this->assertSame((string) ($storedPayload['nonce'] ?? ''), (string) ($query['nonce'] ?? ''));
        $this->assertSame((string) ($storedPayload['redirect_uri'] ?? ''), (string) ($query['redirect_uri'] ?? ''));
        $this->assertStringContainsString('openid', (string) ($query['scope'] ?? ''));
        $this->assertStringContainsString('User.Read', (string) ($query['scope'] ?? ''));
    }

    public function testHandleCallbackReturnsCallbackInvalidWhenInputIsEmpty(): void
    {
        $service = $this->newService();

        $result = $service->handleCallback(' ', '');

        $this->assertFalse($result['ok']);
        $this->assertSame('callback_invalid', $result['error']);
    }

    public function testHandleCallbackReturnsStateErrorWhenStateCannotBeConsumed(): void
    {
        $stateStore = $this->createMock(MicrosoftOidcStateStoreService::class);
        $stateStore->expects($this->once())
            ->method('consume')
            ->with('state-x')
            ->willReturn(['ok' => false, 'error' => 'state_invalid']);

        $tenantGateway = $this->createMock(AuthTenantGateway::class);
        $tenantGateway->expects($this->never())->method('findById');

        $service = $this->newService(null, $tenantGateway, null, $stateStore);
        $result = $service->handleCallback('state-x', 'code-x');

        $this->assertFalse($result['ok']);
        $this->assertSame('state_invalid', $result['error']);
    }

    public function testHandleCallbackReturnsTenantNotFoundForInactiveTenant(): void
    {
        $stateStore = $this->createMock(MicrosoftOidcStateStoreService::class);
        $stateStore->method('consume')->willReturn([
            'ok' => true,
            'entry' => [
                'tenant_id' => 5,
                'nonce' => 'nonce',
                'code_verifier' => 'verifier',
                'redirect_uri' => 'https://app.example/auth/microsoft/callback',
            ],
        ]);

        $tenantGateway = $this->createMock(AuthTenantGateway::class);
        $tenantGateway->expects($this->once())
            ->method('findById')
            ->with(5)
            ->willReturn(['id' => 5, 'status' => 'inactive']);

        $service = $this->newService(null, $tenantGateway, null, $stateStore);
        $result = $service->handleCallback('state-ok', 'code-ok');

        $this->assertFalse($result['ok']);
        $this->assertSame('tenant_not_found', $result['error']);
    }

    public function testHandleCallbackReturnsConfigErrorFromTenantSsoService(): void
    {
        $stateStore = $this->createMock(MicrosoftOidcStateStoreService::class);
        $stateStore->method('consume')->willReturn([
            'ok' => true,
            'entry' => [
                'tenant_id' => 8,
                'nonce' => 'nonce',
                'code_verifier' => 'verifier',
                'redirect_uri' => 'https://app.example/auth/microsoft/callback',
            ],
        ]);

        $tenantGateway = $this->createMock(AuthTenantGateway::class);
        $tenantGateway->expects($this->once())
            ->method('findById')
            ->with(8)
            ->willReturn(['id' => 8, 'status' => 'active']);

        $tenantSsoService = $this->createMock(TenantSsoService::class);
        $tenantSsoService->method('getEffectiveMicrosoftProviderConfig')->willReturn([
            'ok' => false,
            'error' => 'sso_disabled',
        ]);

        $service = $this->newService($tenantSsoService, $tenantGateway, null, $stateStore);
        $result = $service->handleCallback('state-ok', 'code-ok');

        $this->assertFalse($result['ok']);
        $this->assertSame('sso_disabled', $result['error']);
    }

    public function testHandleCallbackReturnsTokenEndpointMissingWhenDiscoveryMetadataIsIncomplete(): void
    {
        $service = $this->newServiceForTokenFlow([
            'status' => 200,
            'data' => json_encode(['issuer' => 'https://issuer.example']) ?: '{}',
        ]);

        $result = $service->handleCallback('state-ok', 'code-ok');

        $this->assertFalse($result['ok']);
        $this->assertSame('token_endpoint_missing', $result['error']);
    }

    public function testHandleCallbackReturnsTokenExchangeFailedWhenTokenCallIsNotSuccessful(): void
    {
        $service = $this->newServiceForTokenFlow(
            [
                'status' => 200,
                'data' => json_encode([
                    'token_endpoint' => 'https://login.microsoftonline.com/tenant/oauth2/v2.0/token',
                    'issuer' => 'https://login.microsoftonline.com/{tenantid}/v2.0',
                    'jwks_uri' => 'https://login.microsoftonline.com/tenant/discovery/v2.0/keys',
                ]) ?: '{}',
            ],
            [
                'status' => 401,
                'data' => json_encode(['error' => 'invalid_grant']) ?: '{}',
            ]
        );

        $result = $service->handleCallback('state-ok', 'code-ok');

        $this->assertFalse($result['ok']);
        $this->assertSame('token_exchange_failed', $result['error']);
    }

    public function testHandleCallbackReturnsTokenResponseInvalidWhenBodyIsNotJson(): void
    {
        $service = $this->newServiceForTokenFlow(
            [
                'status' => 200,
                'data' => json_encode([
                    'token_endpoint' => 'https://login.microsoftonline.com/tenant/oauth2/v2.0/token',
                    'issuer' => 'https://login.microsoftonline.com/{tenantid}/v2.0',
                    'jwks_uri' => 'https://login.microsoftonline.com/tenant/discovery/v2.0/keys',
                ]) ?: '{}',
            ],
            [
                'status' => 200,
                'data' => 'not-json',
            ]
        );

        $result = $service->handleCallback('state-ok', 'code-ok');

        $this->assertFalse($result['ok']);
        $this->assertSame('token_response_invalid', $result['error']);
    }

    public function testHandleCallbackReturnsIdTokenMissingWhenTokenPayloadHasNoIdToken(): void
    {
        $service = $this->newServiceForTokenFlow(
            [
                'status' => 200,
                'data' => json_encode([
                    'token_endpoint' => 'https://login.microsoftonline.com/tenant/oauth2/v2.0/token',
                    'issuer' => 'https://login.microsoftonline.com/{tenantid}/v2.0',
                    'jwks_uri' => 'https://login.microsoftonline.com/tenant/discovery/v2.0/keys',
                ]) ?: '{}',
            ],
            [
                'status' => 200,
                'data' => json_encode(['access_token' => 'access']) ?: '{}',
            ]
        );

        $result = $service->handleCallback('state-ok', 'code-ok');

        $this->assertFalse($result['ok']);
        $this->assertSame('id_token_missing', $result['error']);
    }

    public function testHandleCallbackReturnsValidationErrorWhenIdTokenIsMalformed(): void
    {
        $service = $this->newServiceForTokenFlow(
            [
                'status' => 200,
                'data' => json_encode([
                    'token_endpoint' => 'https://login.microsoftonline.com/tenant/oauth2/v2.0/token',
                    'issuer' => 'https://login.microsoftonline.com/{tenantid}/v2.0',
                    'jwks_uri' => 'https://login.microsoftonline.com/tenant/discovery/v2.0/keys',
                ]) ?: '{}',
            ],
            [
                'status' => 200,
                'data' => json_encode(['id_token' => 'not-a-jwt']) ?: '{}',
            ]
        );

        $result = $service->handleCallback('state-ok', 'code-ok');

        $this->assertFalse($result['ok']);
        $this->assertSame('id_token_format_invalid', $result['error']);
    }

    public function testHandleCallbackReturnsClaimsForValidToken(): void
    {
        [$idToken, $jwk] = $this->buildValidIdToken('client-id', 'tenant-id-123', 'nonce-123');

        $stateStore = $this->createMock(MicrosoftOidcStateStoreService::class);
        $stateStore->method('consume')->willReturn([
            'ok' => true,
            'entry' => [
                'tenant_id' => 12,
                'nonce' => 'nonce-123',
                'code_verifier' => 'verifier-123',
                'redirect_uri' => 'https://app.example/auth/microsoft/callback',
                'locale' => 'de',
            ],
        ]);

        $tenantGateway = $this->createMock(AuthTenantGateway::class);
        $tenantGateway->expects($this->once())
            ->method('findById')
            ->with(12)
            ->willReturn([
                'id' => 12,
                'status' => 'active',
                'uuid' => 'tenant-uuid',
            ]);

        $tenantSsoService = $this->createMock(TenantSsoService::class);
        $tenantSsoService->method('getEffectiveMicrosoftProviderConfig')->willReturn([
            'ok' => true,
            'config' => [
                'authority' => 'https://login.microsoftonline.com/tenant-id-123/v2.0',
                'client_id' => 'client-id',
                'client_secret' => 'client-secret',
                'tenant_id' => 'tenant-id-123',
                'allowed_domains' => 'example.com',
                'sync_profile_on_login' => 0,
                'sync_profile_fields' => [],
            ],
        ]);
        $tenantSsoService->method('isEmailDomainAllowed')->willReturn(true);
        $tenantSsoService->method('normalizeProfileSyncFields')->willReturn([]);

        $oidcGateway = $this->createMock(OidcHttpGateway::class);
        $oidcGateway->expects($this->exactly(3))
            ->method('call')
            ->willReturnOnConsecutiveCalls(
                [
                    'status' => 200,
                    'data' => json_encode([
                        'token_endpoint' => 'https://login.microsoftonline.com/tenant-id-123/oauth2/v2.0/token',
                        'issuer' => 'https://login.microsoftonline.com/{tenantid}/v2.0',
                        'jwks_uri' => 'https://login.microsoftonline.com/tenant-id-123/discovery/v2.0/keys',
                    ]) ?: '{}',
                ],
                [
                    'status' => 200,
                    'data' => json_encode([
                        'id_token' => $idToken,
                        'access_token' => '',
                    ]) ?: '{}',
                ],
                [
                    'status' => 200,
                    'data' => json_encode(['keys' => [$jwk]]) ?: '{}',
                ],
            );

        $service = $this->newService($tenantSsoService, $tenantGateway, $oidcGateway, $stateStore);
        $result = $service->handleCallback('state-ok', 'code-ok');

        $this->assertTrue($result['ok']);
        $this->assertSame('de', (string) ($result['locale'] ?? ''));
        $this->assertSame('user@example.com', (string) (($result['claims'] ?? [])['email'] ?? ''));
        $this->assertSame('oid-123', (string) (($result['claims'] ?? [])['oid'] ?? ''));
        $this->assertSame('sub-123', (string) (($result['claims'] ?? [])['subject'] ?? ''));
        $this->assertSame('tenant-id-123', (string) (($result['claims'] ?? [])['tid'] ?? ''));
    }

    public function testHandleCallbackReturnsEmailDomainNotAllowedWhenTenantDomainPolicyRejectsUser(): void
    {
        [$idToken, $jwk] = $this->buildValidIdToken('client-id', 'tenant-id-123', 'nonce-123');

        $stateStore = $this->createMock(MicrosoftOidcStateStoreService::class);
        $stateStore->method('consume')->willReturn([
            'ok' => true,
            'entry' => [
                'tenant_id' => 12,
                'nonce' => 'nonce-123',
                'code_verifier' => 'verifier-123',
                'redirect_uri' => 'https://app.example/auth/microsoft/callback',
                'locale' => 'de',
            ],
        ]);

        $tenantGateway = $this->createMock(AuthTenantGateway::class);
        $tenantGateway->expects($this->once())
            ->method('findById')
            ->with(12)
            ->willReturn([
                'id' => 12,
                'status' => 'active',
                'uuid' => 'tenant-uuid',
            ]);

        $tenantSsoService = $this->createMock(TenantSsoService::class);
        $tenantSsoService->method('getEffectiveMicrosoftProviderConfig')->willReturn([
            'ok' => true,
            'config' => [
                'authority' => 'https://login.microsoftonline.com/tenant-id-123/v2.0',
                'client_id' => 'client-id',
                'client_secret' => 'client-secret',
                'tenant_id' => 'tenant-id-123',
                'allowed_domains' => 'allowed.example.com',
                'sync_profile_on_login' => 0,
                'sync_profile_fields' => [],
            ],
        ]);
        $tenantSsoService->method('normalizeProfileSyncFields')->willReturn([]);
        $tenantSsoService->expects($this->once())
            ->method('isEmailDomainAllowed')
            ->with('user@example.com', ['allowed.example.com'])
            ->willReturn(false);

        $oidcGateway = $this->createMock(OidcHttpGateway::class);
        $oidcGateway->expects($this->exactly(3))
            ->method('call')
            ->willReturnOnConsecutiveCalls(
                [
                    'status' => 200,
                    'data' => json_encode([
                        'token_endpoint' => 'https://login.microsoftonline.com/tenant-id-123/oauth2/v2.0/token',
                        'issuer' => 'https://login.microsoftonline.com/{tenantid}/v2.0',
                        'jwks_uri' => 'https://login.microsoftonline.com/tenant-id-123/discovery/v2.0/keys',
                    ]) ?: '{}',
                ],
                [
                    'status' => 200,
                    'data' => json_encode([
                        'id_token' => $idToken,
                        'access_token' => '',
                    ]) ?: '{}',
                ],
                [
                    'status' => 200,
                    'data' => json_encode(['keys' => [$jwk]]) ?: '{}',
                ],
            );

        $service = $this->newService($tenantSsoService, $tenantGateway, $oidcGateway, $stateStore);
        $result = $service->handleCallback('state-ok', 'code-ok');

        $this->assertFalse($result['ok']);
        $this->assertSame('email_domain_not_allowed', $result['error']);
    }

    private function newServiceForTokenFlow(array $discoveryResponse, ?array $tokenResponse = null): MicrosoftOidcService
    {
        $stateStore = $this->createMock(MicrosoftOidcStateStoreService::class);
        $stateStore->method('consume')->willReturn([
            'ok' => true,
            'entry' => [
                'tenant_id' => 8,
                'nonce' => 'nonce-1',
                'code_verifier' => 'verifier-1',
                'redirect_uri' => 'https://app.example/auth/microsoft/callback',
                'locale' => 'de',
            ],
        ]);

        $tenantGateway = $this->createMock(AuthTenantGateway::class);
        $tenantGateway->method('findById')->willReturn([
            'id' => 8,
            'status' => 'active',
        ]);

        $tenantSsoService = $this->createMock(TenantSsoService::class);
        $tenantSsoService->method('getEffectiveMicrosoftProviderConfig')->willReturn([
            'ok' => true,
            'config' => [
                'authority' => 'https://login.microsoftonline.com/tenant/v2.0',
                'client_id' => 'client-id',
                'client_secret' => 'client-secret',
                'tenant_id' => 'tenant-id-123',
                'allowed_domains' => 'example.com',
                'sync_profile_on_login' => 0,
                'sync_profile_fields' => [],
            ],
        ]);
        $tenantSsoService->method('normalizeProfileSyncFields')->willReturn([]);

        $oidcGateway = $this->createMock(OidcHttpGateway::class);
        if ($tokenResponse === null) {
            $oidcGateway->method('call')->willReturn($discoveryResponse);
        } else {
            $oidcGateway->method('call')->willReturnOnConsecutiveCalls($discoveryResponse, $tokenResponse);
        }

        return $this->newService($tenantSsoService, $tenantGateway, $oidcGateway, $stateStore);
    }

    private function newService(
        ?TenantSsoService $tenantSsoService = null,
        ?AuthTenantGateway $tenantGateway = null,
        ?OidcHttpGateway $oidcGateway = null,
        ?MicrosoftOidcStateStoreService $stateStore = null
    ): MicrosoftOidcService {
        if ($tenantSsoService === null) {
            $tenantSsoService = $this->createMock(TenantSsoService::class);
            $tenantSsoService->method('normalizeProfileSyncFields')->willReturn([]);
            $tenantSsoService->method('profileSyncFieldsRequireGraph')->willReturn(false);
            $tenantSsoService->method('defaultProfileSyncFields')->willReturn(['first_name', 'last_name']);
            $tenantSsoService->method('isEmailDomainAllowed')->willReturn(true);
            $tenantSsoService->method('getEffectiveMicrosoftProviderConfig')->willReturn([
                'ok' => true,
                'config' => [
                    'authority' => 'https://login.microsoftonline.com/common/v2.0',
                    'client_id' => 'client-id',
                    'client_secret' => 'client-secret',
                    'tenant_id' => 'tenant-id-123',
                    'allowed_domains' => 'example.com',
                    'sync_profile_on_login' => 0,
                    'sync_profile_fields' => [],
                ],
            ]);
        }

        $tenantGateway = $tenantGateway ?? $this->createMock(AuthTenantGateway::class);
        $oidcGateway = $oidcGateway ?? $this->createMock(OidcHttpGateway::class);
        $stateStore = $stateStore ?? $this->createMock(MicrosoftOidcStateStoreService::class);

        return new MicrosoftOidcService(
            $tenantSsoService,
            $tenantGateway,
            $oidcGateway,
            $stateStore
        );
    }

    /**
     * @return array{0:string,1:array<string,string>}
     */
    private function buildValidIdToken(string $clientId, string $tenantId, string $nonce): array
    {
        $key = openssl_pkey_new([
            'private_key_type' => OPENSSL_KEYTYPE_RSA,
            'private_key_bits' => 2048,
        ]);
        if ($key === false) {
            $this->fail('Unable to generate RSA key for OIDC token test');
        }

        $details = openssl_pkey_get_details($key);
        if (!is_array($details) || !is_array($details['rsa'] ?? null)) {
            $this->fail('Unable to read RSA key details');
        }

        $header = ['alg' => 'RS256', 'kid' => 'kid-123', 'typ' => 'JWT'];
        $claims = [
            'aud' => $clientId,
            'nonce' => $nonce,
            'tid' => $tenantId,
            'iss' => 'https://login.microsoftonline.com/' . $tenantId . '/v2.0',
            'exp' => time() + 300,
            'nbf' => time() - 10,
            'oid' => 'oid-123',
            'sub' => 'sub-123',
            'email' => 'user@example.com',
            'name' => 'Example User',
        ];

        $headerJson = json_encode($header);
        $claimsJson = json_encode($claims);
        if (!is_string($headerJson) || !is_string($claimsJson)) {
            $this->fail('Unable to encode token payload');
        }

        $encodedHeader = self::base64UrlEncode($headerJson);
        $encodedClaims = self::base64UrlEncode($claimsJson);
        $signingInput = $encodedHeader . '.' . $encodedClaims;

        $signature = '';
        $signed = openssl_sign($signingInput, $signature, $key, OPENSSL_ALGO_SHA256);
        if (!$signed) {
            $this->fail('Unable to sign token payload');
        }

        $rsa = $details['rsa'];
        $modulus = is_string($rsa['n'] ?? null) ? $rsa['n'] : '';
        $exponent = is_string($rsa['e'] ?? null) ? $rsa['e'] : '';
        if ($modulus === '' || $exponent === '') {
            $this->fail('Missing RSA modulus/exponent');
        }

        return [
            $signingInput . '.' . self::base64UrlEncode($signature),
            [
                'kid' => 'kid-123',
                'n' => self::base64UrlEncode($modulus),
                'e' => self::base64UrlEncode($exponent),
            ],
        ];
    }

    /**
     * @return array<string, string>
     */
    private function queryFromUrl(string $url): array
    {
        $parts = parse_url($url);
        if (!is_array($parts)) {
            return [];
        }

        $query = [];
        parse_str((string) ($parts['query'] ?? ''), $query);
        return array_map(static fn ($value): string => (string) $value, $query);
    }

    private static function base64UrlEncode(string $value): string
    {
        return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
    }
}
