<?php

namespace MintyPHP\Tests\Service\Auth;

use MintyPHP\Repository\Tenant\UserTenantRepositoryInterface;
use MintyPHP\Repository\User\UserReadRepositoryInterface;
use MintyPHP\Repository\User\UserWriteRepositoryInterface;
use MintyPHP\Service\Auth\AuthExternalIdentityGateway;
use MintyPHP\Service\Auth\AuthSettingsGateway;
use MintyPHP\Service\Auth\SsoUserLinkService;
use MintyPHP\Service\Auth\TenantSsoService;
use MintyPHP\Service\User\UserAssignmentService;
use MintyPHP\Service\User\UserAvatarService;
use PHPUnit\Framework\TestCase;

class SsoUserLinkServiceTest extends TestCase
{
    private const BASE_CLAIMS = [
        'tid' => 'ms-tenant-id',
        'oid' => 'ms-object-id',
        'issuer' => 'https://login.microsoftonline.com/tid/v2.0',
        'subject' => 'subject-123',
        'email' => 'user@example.com',
        'given_name' => 'Ada',
        'family_name' => 'Lovelace',
        'name' => 'Ada Lovelace',
    ];

    private const BASE_TENANT = ['id' => 1];

    // ---------------------------------------------------------------
    // Identity validation errors
    // ---------------------------------------------------------------

    public function testIdentityInvalidWhenTidIsMissing(): void
    {
        $service = $this->newService();
        $claims = self::BASE_CLAIMS;
        $claims['tid'] = '';

        $result = $service->linkOrProvisionMicrosoftUser(self::BASE_TENANT, $claims);

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

    public function testIdentityInvalidWhenOidIsMissing(): void
    {
        $service = $this->newService();
        $claims = self::BASE_CLAIMS;
        $claims['oid'] = '';

        $result = $service->linkOrProvisionMicrosoftUser(self::BASE_TENANT, $claims);

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

    public function testIdentityInvalidWhenIssuerIsMissing(): void
    {
        $service = $this->newService();
        $claims = self::BASE_CLAIMS;
        $claims['issuer'] = '';

        $result = $service->linkOrProvisionMicrosoftUser(self::BASE_TENANT, $claims);

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

    public function testIdentityInvalidWhenSubjectIsMissing(): void
    {
        $service = $this->newService();
        $claims = self::BASE_CLAIMS;
        $claims['subject'] = '';

        $result = $service->linkOrProvisionMicrosoftUser(self::BASE_TENANT, $claims);

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

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

        $result = $service->linkOrProvisionMicrosoftUser(['id' => 0], self::BASE_CLAIMS);

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

    // ---------------------------------------------------------------
    // Phase 1: OID identity match
    // ---------------------------------------------------------------

    public function testPhase1OidMatchReturnsExistingUserAndSyncsProfile(): void
    {
        $identityGateway = $this->createMock(AuthExternalIdentityGateway::class);
        $identityGateway->method('findByProviderTidOid')->willReturn(['user_id' => 42]);
        $identityGateway->expects($this->never())->method('findByProviderIssuerSubject');

        $userReadRepo = $this->createMock(UserReadRepositoryInterface::class);
        $userReadRepo->expects($this->any())->method('find')->with(42)->willReturn(['id' => 42, 'active' => 1]);

        $userTenantRepo = $this->createMock(UserTenantRepositoryInterface::class);
        $userTenantRepo->expects($this->any())->method('listTenantIdsByUserId')->with(42)->willReturn([1, 2]);

        $tenantSsoService = $this->createMock(TenantSsoService::class);
        $tenantSsoService->method('microsoftProviderKey')->willReturn('microsoft');

        $service = $this->newService(
            userReadRepository: $userReadRepo,
            userTenantRepository: $userTenantRepo,
            tenantSsoService: $tenantSsoService,
            externalIdentityGateway: $identityGateway
        );

        $result = $service->linkOrProvisionMicrosoftUser(self::BASE_TENANT, self::BASE_CLAIMS);

        $this->assertTrue($result['ok']);
        $this->assertSame(42, $result['user_id']);
        $this->assertFalse($result['created']);
    }

    // ---------------------------------------------------------------
    // Phase 2: Issuer/Subject match
    // ---------------------------------------------------------------

    public function testPhase2IssuerSubjectMatchReturnsExistingUser(): void
    {
        $identityGateway = $this->createMock(AuthExternalIdentityGateway::class);
        $identityGateway->method('findByProviderTidOid')->willReturn(null);
        $identityGateway->method('findByProviderIssuerSubject')->willReturn(['user_id' => 43]);

        $userReadRepo = $this->createMock(UserReadRepositoryInterface::class);
        $userReadRepo->expects($this->any())->method('find')->with(43)->willReturn(['id' => 43, 'active' => 1]);

        $userTenantRepo = $this->createMock(UserTenantRepositoryInterface::class);
        $userTenantRepo->expects($this->any())->method('listTenantIdsByUserId')->with(43)->willReturn([1]);

        $tenantSsoService = $this->createMock(TenantSsoService::class);
        $tenantSsoService->method('microsoftProviderKey')->willReturn('microsoft');

        $service = $this->newService(
            userReadRepository: $userReadRepo,
            userTenantRepository: $userTenantRepo,
            tenantSsoService: $tenantSsoService,
            externalIdentityGateway: $identityGateway
        );

        $result = $service->linkOrProvisionMicrosoftUser(self::BASE_TENANT, self::BASE_CLAIMS);

        $this->assertTrue($result['ok']);
        $this->assertSame(43, $result['user_id']);
        $this->assertFalse($result['created']);
    }

    // ---------------------------------------------------------------
    // Identity found but user inactive / tenant not assigned
    // ---------------------------------------------------------------

    public function testIdentityFoundButUserInactive(): void
    {
        $identityGateway = $this->createMock(AuthExternalIdentityGateway::class);
        $identityGateway->method('findByProviderTidOid')->willReturn(['user_id' => 44]);

        $userReadRepo = $this->createMock(UserReadRepositoryInterface::class);
        $userReadRepo->expects($this->any())->method('find')->with(44)->willReturn(['id' => 44, 'active' => 0]);

        $tenantSsoService = $this->createMock(TenantSsoService::class);
        $tenantSsoService->method('microsoftProviderKey')->willReturn('microsoft');

        $service = $this->newService(
            userReadRepository: $userReadRepo,
            tenantSsoService: $tenantSsoService,
            externalIdentityGateway: $identityGateway
        );

        $result = $service->linkOrProvisionMicrosoftUser(self::BASE_TENANT, self::BASE_CLAIMS);

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

    public function testIdentityFoundButUserNotFound(): void
    {
        $identityGateway = $this->createMock(AuthExternalIdentityGateway::class);
        $identityGateway->method('findByProviderTidOid')->willReturn(['user_id' => 45]);

        $userReadRepo = $this->createMock(UserReadRepositoryInterface::class);
        $userReadRepo->expects($this->any())->method('find')->with(45)->willReturn(null);

        $tenantSsoService = $this->createMock(TenantSsoService::class);
        $tenantSsoService->method('microsoftProviderKey')->willReturn('microsoft');

        $service = $this->newService(
            userReadRepository: $userReadRepo,
            tenantSsoService: $tenantSsoService,
            externalIdentityGateway: $identityGateway
        );

        $result = $service->linkOrProvisionMicrosoftUser(self::BASE_TENANT, self::BASE_CLAIMS);

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

    public function testIdentityFoundButTenantNotAssigned(): void
    {
        $identityGateway = $this->createMock(AuthExternalIdentityGateway::class);
        $identityGateway->method('findByProviderTidOid')->willReturn(['user_id' => 46]);

        $userReadRepo = $this->createMock(UserReadRepositoryInterface::class);
        $userReadRepo->expects($this->any())->method('find')->with(46)->willReturn(['id' => 46, 'active' => 1]);

        $userTenantRepo = $this->createMock(UserTenantRepositoryInterface::class);
        $userTenantRepo->expects($this->any())->method('listTenantIdsByUserId')->with(46)->willReturn([99, 100]);

        $tenantSsoService = $this->createMock(TenantSsoService::class);
        $tenantSsoService->method('microsoftProviderKey')->willReturn('microsoft');

        $service = $this->newService(
            userReadRepository: $userReadRepo,
            userTenantRepository: $userTenantRepo,
            tenantSsoService: $tenantSsoService,
            externalIdentityGateway: $identityGateway
        );

        $result = $service->linkOrProvisionMicrosoftUser(self::BASE_TENANT, self::BASE_CLAIMS);

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

    // ---------------------------------------------------------------
    // Email linking: email exists, user active
    // ---------------------------------------------------------------

    public function testEmailLinkingCreatesIdentityLink(): void
    {
        $identityGateway = $this->createMock(AuthExternalIdentityGateway::class);
        $identityGateway->method('findByProviderTidOid')->willReturn(null);
        $identityGateway->method('findByProviderIssuerSubject')->willReturn(null);
        $identityGateway->expects($this->once())
            ->method('createLink')
            ->with($this->callback(function (array $data): bool {
                return ($data['user_id'] ?? 0) === 50
                    && ($data['provider'] ?? '') === 'microsoft'
                    && ($data['oid'] ?? '') === 'ms-object-id';
            }));

        $userReadRepo = $this->createMock(UserReadRepositoryInterface::class);
        $userReadRepo->expects($this->any())->method('findByEmail')->with('user@example.com')->willReturn([
            'id' => 50,
            'active' => 1,
            'email' => 'user@example.com',
        ]);
        // find() is called by syncProfileFromMicrosoft
        $userReadRepo->method('find')->willReturn(['id' => 50, 'active' => 1, 'uuid' => 'u-uuid']);

        $userTenantRepo = $this->createMock(UserTenantRepositoryInterface::class);
        $userTenantRepo->method('listTenantIdsByUserId')->willReturn([1]);

        $tenantSsoService = $this->createMock(TenantSsoService::class);
        $tenantSsoService->method('microsoftProviderKey')->willReturn('microsoft');

        $service = $this->newService(
            userReadRepository: $userReadRepo,
            userTenantRepository: $userTenantRepo,
            tenantSsoService: $tenantSsoService,
            externalIdentityGateway: $identityGateway
        );

        $result = $service->linkOrProvisionMicrosoftUser(self::BASE_TENANT, self::BASE_CLAIMS);

        $this->assertTrue($result['ok']);
        $this->assertSame(50, $result['user_id']);
        $this->assertFalse($result['created']);
    }

    public function testEmailLinkingReturnsInactiveWhenUserIsInactive(): void
    {
        $identityGateway = $this->createMock(AuthExternalIdentityGateway::class);
        $identityGateway->method('findByProviderTidOid')->willReturn(null);
        $identityGateway->method('findByProviderIssuerSubject')->willReturn(null);

        $userReadRepo = $this->createMock(UserReadRepositoryInterface::class);
        $userReadRepo->method('findByEmail')->willReturn([
            'id' => 51,
            'active' => 0,
        ]);

        $tenantSsoService = $this->createMock(TenantSsoService::class);
        $tenantSsoService->method('microsoftProviderKey')->willReturn('microsoft');

        $service = $this->newService(
            userReadRepository: $userReadRepo,
            tenantSsoService: $tenantSsoService,
            externalIdentityGateway: $identityGateway
        );

        $result = $service->linkOrProvisionMicrosoftUser(self::BASE_TENANT, self::BASE_CLAIMS);

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

    // ---------------------------------------------------------------
    // Email linking ensures tenant assignment
    // ---------------------------------------------------------------

    public function testEmailLinkingEnsuresTenantAssignment(): void
    {
        $identityGateway = $this->createMock(AuthExternalIdentityGateway::class);
        $identityGateway->method('findByProviderTidOid')->willReturn(null);
        $identityGateway->method('findByProviderIssuerSubject')->willReturn(null);
        $identityGateway->method('createLink')->willReturn(true);

        $userReadRepo = $this->createMock(UserReadRepositoryInterface::class);
        $userReadRepo->method('findByEmail')->willReturn(['id' => 52, 'active' => 1]);
        $userReadRepo->method('find')->willReturn(['id' => 52, 'active' => 1, 'uuid' => 'u-uuid']);

        // User is NOT assigned to tenant 1 yet
        $userTenantRepo = $this->createMock(UserTenantRepositoryInterface::class);
        $userTenantRepo->method('listTenantIdsByUserId')->willReturn([99]);

        $userAssignmentService = $this->createMock(UserAssignmentService::class);
        $userAssignmentService->expects($this->once())
            ->method('syncTenants')
            ->with(52, $this->callback(function (array $ids): bool {
                return in_array(1, $ids, true) && in_array(99, $ids, true);
            }));

        $tenantSsoService = $this->createMock(TenantSsoService::class);
        $tenantSsoService->method('microsoftProviderKey')->willReturn('microsoft');

        $service = $this->newService(
            userReadRepository: $userReadRepo,
            userAssignmentService: $userAssignmentService,
            userTenantRepository: $userTenantRepo,
            tenantSsoService: $tenantSsoService,
            externalIdentityGateway: $identityGateway
        );

        $result = $service->linkOrProvisionMicrosoftUser(self::BASE_TENANT, self::BASE_CLAIMS);

        $this->assertTrue($result['ok']);
    }

    // ---------------------------------------------------------------
    // Email missing: no identity, no email
    // ---------------------------------------------------------------

    public function testEmailMissingWhenNoIdentityAndNoEmail(): void
    {
        $identityGateway = $this->createMock(AuthExternalIdentityGateway::class);
        $identityGateway->method('findByProviderTidOid')->willReturn(null);
        $identityGateway->method('findByProviderIssuerSubject')->willReturn(null);

        $tenantSsoService = $this->createMock(TenantSsoService::class);
        $tenantSsoService->method('microsoftProviderKey')->willReturn('microsoft');

        $service = $this->newService(
            tenantSsoService: $tenantSsoService,
            externalIdentityGateway: $identityGateway
        );

        $claims = self::BASE_CLAIMS;
        $claims['email'] = '';

        $result = $service->linkOrProvisionMicrosoftUser(self::BASE_TENANT, $claims);

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

    public function testEmailMissingWhenInvalidEmailFormat(): void
    {
        $identityGateway = $this->createMock(AuthExternalIdentityGateway::class);
        $identityGateway->method('findByProviderTidOid')->willReturn(null);
        $identityGateway->method('findByProviderIssuerSubject')->willReturn(null);

        $tenantSsoService = $this->createMock(TenantSsoService::class);
        $tenantSsoService->method('microsoftProviderKey')->willReturn('microsoft');

        $service = $this->newService(
            tenantSsoService: $tenantSsoService,
            externalIdentityGateway: $identityGateway
        );

        $claims = self::BASE_CLAIMS;
        $claims['email'] = 'not-an-email';

        $result = $service->linkOrProvisionMicrosoftUser(self::BASE_TENANT, $claims);

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

    // ---------------------------------------------------------------
    // Full provisioning: creates new user
    // ---------------------------------------------------------------

    public function testFullProvisioningCreatesNewUser(): void
    {
        $identityGateway = $this->createMock(AuthExternalIdentityGateway::class);
        $identityGateway->method('findByProviderTidOid')->willReturn(null);
        $identityGateway->method('findByProviderIssuerSubject')->willReturn(null);
        $identityGateway->expects($this->once())->method('createLink');

        $userReadRepo = $this->createMock(UserReadRepositoryInterface::class);
        $userReadRepo->method('findByEmail')->willReturn(null);
        $userReadRepo->method('find')->willReturn(['id' => 100, 'uuid' => 'new-uuid', 'active' => 1]);

        $userWriteRepo = $this->createMock(UserWriteRepositoryInterface::class);
        $userWriteRepo->expects($this->once())
            ->method('create')
            ->with($this->callback(function (array $data): bool {
                return ($data['first_name'] ?? '') === 'Ada'
                    && ($data['last_name'] ?? '') === 'Lovelace'
                    && ($data['email'] ?? '') === 'user@example.com'
                    && str_starts_with($data['password'] ?? '', 'sso-')
                    && str_ends_with($data['password'] ?? '', '-A1!')
                    && ($data['primary_tenant_id'] ?? 0) === 1
                    && ($data['current_tenant_id'] ?? 0) === 1
                    && ($data['active'] ?? 0) === 1;
            }))
            ->willReturn(100);
        $userWriteRepo->expects($this->once())->method('setEmailVerified')->with(100);

        $userAssignmentService = $this->createMock(UserAssignmentService::class);
        $userAssignmentService->expects($this->once())->method('syncTenants')->with(100, [1]);
        $userAssignmentService->expects($this->once())->method('syncRoles')->with(100, [5]);
        $userAssignmentService->expects($this->once())->method('syncDepartments')->with(100, [3]);

        $settingsGateway = $this->createMock(AuthSettingsGateway::class);
        $settingsGateway->method('getDefaultRoleId')->willReturn(5);
        $settingsGateway->method('getDefaultDepartmentId')->willReturn(3);
        $settingsGateway->expects($this->any())->method('normalizeTheme')->willReturn('light');

        $tenantSsoService = $this->createMock(TenantSsoService::class);
        $tenantSsoService->method('microsoftProviderKey')->willReturn('microsoft');

        $service = $this->newService(
            userReadRepository: $userReadRepo,
            userWriteRepository: $userWriteRepo,
            userAssignmentService: $userAssignmentService,
            settingsGateway: $settingsGateway,
            tenantSsoService: $tenantSsoService,
            externalIdentityGateway: $identityGateway
        );

        $result = $service->linkOrProvisionMicrosoftUser(self::BASE_TENANT, self::BASE_CLAIMS);

        $this->assertTrue($result['ok']);
        $this->assertSame(100, $result['user_id']);
        $this->assertTrue($result['created']);
    }

    public function testFullProvisioningParsesFullNameWhenGivenNameMissing(): void
    {
        $identityGateway = $this->createMock(AuthExternalIdentityGateway::class);
        $identityGateway->method('findByProviderTidOid')->willReturn(null);
        $identityGateway->method('findByProviderIssuerSubject')->willReturn(null);
        $identityGateway->method('createLink')->willReturn(true);

        $userReadRepo = $this->createMock(UserReadRepositoryInterface::class);
        $userReadRepo->method('findByEmail')->willReturn(null);
        $userReadRepo->method('find')->willReturn(['id' => 101, 'uuid' => 'u-uuid', 'active' => 1]);

        $userWriteRepo = $this->createMock(UserWriteRepositoryInterface::class);
        $userWriteRepo->expects($this->once())
            ->method('create')
            ->with($this->callback(function (array $data): bool {
                return ($data['first_name'] ?? '') === 'Marie'
                    && ($data['last_name'] ?? '') === 'Von Curie';
            }))
            ->willReturn(101);
        $userWriteRepo->method('setEmailVerified')->willReturn(true);

        $settingsGateway = $this->createMock(AuthSettingsGateway::class);
        $settingsGateway->method('getDefaultRoleId')->willReturn(0);
        $settingsGateway->method('getDefaultDepartmentId')->willReturn(0);
        $settingsGateway->method('normalizeTheme')->willReturn('light');

        $tenantSsoService = $this->createMock(TenantSsoService::class);
        $tenantSsoService->method('microsoftProviderKey')->willReturn('microsoft');

        $service = $this->newService(
            userReadRepository: $userReadRepo,
            userWriteRepository: $userWriteRepo,
            settingsGateway: $settingsGateway,
            tenantSsoService: $tenantSsoService,
            externalIdentityGateway: $identityGateway
        );

        $claims = self::BASE_CLAIMS;
        $claims['given_name'] = '';
        $claims['family_name'] = '';
        $claims['name'] = 'Marie Von Curie';

        $result = $service->linkOrProvisionMicrosoftUser(self::BASE_TENANT, $claims);

        $this->assertTrue($result['ok']);
        $this->assertTrue($result['created']);
    }

    public function testFullProvisioningUsesEmailPrefixWhenNoNameAvailable(): void
    {
        $identityGateway = $this->createMock(AuthExternalIdentityGateway::class);
        $identityGateway->method('findByProviderTidOid')->willReturn(null);
        $identityGateway->method('findByProviderIssuerSubject')->willReturn(null);
        $identityGateway->method('createLink')->willReturn(true);

        $userReadRepo = $this->createMock(UserReadRepositoryInterface::class);
        $userReadRepo->method('findByEmail')->willReturn(null);
        $userReadRepo->method('find')->willReturn(['id' => 102, 'uuid' => 'u-uuid', 'active' => 1]);

        $userWriteRepo = $this->createMock(UserWriteRepositoryInterface::class);
        $userWriteRepo->expects($this->once())
            ->method('create')
            ->with($this->callback(function (array $data): bool {
                // first_name should be "User" (ucfirst of email prefix)
                return ($data['first_name'] ?? '') === 'User'
                    && ($data['last_name'] ?? '') === '';
            }))
            ->willReturn(102);
        $userWriteRepo->method('setEmailVerified')->willReturn(true);

        $settingsGateway = $this->createMock(AuthSettingsGateway::class);
        $settingsGateway->method('getDefaultRoleId')->willReturn(0);
        $settingsGateway->method('getDefaultDepartmentId')->willReturn(0);
        $settingsGateway->method('normalizeTheme')->willReturn('auto');

        $tenantSsoService = $this->createMock(TenantSsoService::class);
        $tenantSsoService->method('microsoftProviderKey')->willReturn('microsoft');

        $service = $this->newService(
            userReadRepository: $userReadRepo,
            userWriteRepository: $userWriteRepo,
            settingsGateway: $settingsGateway,
            tenantSsoService: $tenantSsoService,
            externalIdentityGateway: $identityGateway
        );

        $claims = self::BASE_CLAIMS;
        $claims['given_name'] = '';
        $claims['family_name'] = '';
        $claims['name'] = '';

        $result = $service->linkOrProvisionMicrosoftUser(self::BASE_TENANT, $claims);

        $this->assertTrue($result['ok']);
    }

    public function testFullProvisioningSkipsRoleAndDeptWhenDefaultsAreZero(): void
    {
        $identityGateway = $this->createMock(AuthExternalIdentityGateway::class);
        $identityGateway->method('findByProviderTidOid')->willReturn(null);
        $identityGateway->method('findByProviderIssuerSubject')->willReturn(null);
        $identityGateway->method('createLink')->willReturn(true);

        $userReadRepo = $this->createMock(UserReadRepositoryInterface::class);
        $userReadRepo->method('findByEmail')->willReturn(null);
        $userReadRepo->method('find')->willReturn(['id' => 103, 'uuid' => 'u-uuid', 'active' => 1]);

        $userWriteRepo = $this->createMock(UserWriteRepositoryInterface::class);
        $userWriteRepo->method('create')->willReturn(103);
        $userWriteRepo->method('setEmailVerified')->willReturn(true);

        $userAssignmentService = $this->createMock(UserAssignmentService::class);
        $userAssignmentService->expects($this->once())->method('syncTenants');
        $userAssignmentService->expects($this->never())->method('syncRoles');
        $userAssignmentService->expects($this->never())->method('syncDepartments');

        $settingsGateway = $this->createMock(AuthSettingsGateway::class);
        $settingsGateway->method('getDefaultRoleId')->willReturn(0);
        $settingsGateway->method('getDefaultDepartmentId')->willReturn(0);
        $settingsGateway->method('normalizeTheme')->willReturn('auto');

        $tenantSsoService = $this->createMock(TenantSsoService::class);
        $tenantSsoService->method('microsoftProviderKey')->willReturn('microsoft');

        $service = $this->newService(
            userReadRepository: $userReadRepo,
            userWriteRepository: $userWriteRepo,
            userAssignmentService: $userAssignmentService,
            settingsGateway: $settingsGateway,
            tenantSsoService: $tenantSsoService,
            externalIdentityGateway: $identityGateway
        );

        $result = $service->linkOrProvisionMicrosoftUser(self::BASE_TENANT, self::BASE_CLAIMS);

        $this->assertTrue($result['ok']);
    }

    // ---------------------------------------------------------------
    // user_create_failed
    // ---------------------------------------------------------------

    public function testUserCreateFailedWhenRepositoryReturnsFalse(): void
    {
        $identityGateway = $this->createMock(AuthExternalIdentityGateway::class);
        $identityGateway->method('findByProviderTidOid')->willReturn(null);
        $identityGateway->method('findByProviderIssuerSubject')->willReturn(null);

        $userReadRepo = $this->createMock(UserReadRepositoryInterface::class);
        $userReadRepo->method('findByEmail')->willReturn(null);

        $userWriteRepo = $this->createMock(UserWriteRepositoryInterface::class);
        $userWriteRepo->method('create')->willReturn(false);

        $settingsGateway = $this->createMock(AuthSettingsGateway::class);
        $settingsGateway->method('normalizeTheme')->willReturn('auto');

        $tenantSsoService = $this->createMock(TenantSsoService::class);
        $tenantSsoService->method('microsoftProviderKey')->willReturn('microsoft');

        $service = $this->newService(
            userReadRepository: $userReadRepo,
            userWriteRepository: $userWriteRepo,
            settingsGateway: $settingsGateway,
            tenantSsoService: $tenantSsoService,
            externalIdentityGateway: $identityGateway
        );

        $result = $service->linkOrProvisionMicrosoftUser(self::BASE_TENANT, self::BASE_CLAIMS);

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

    public function testUserCreateFailedWhenRepositoryReturnsZero(): void
    {
        $identityGateway = $this->createMock(AuthExternalIdentityGateway::class);
        $identityGateway->method('findByProviderTidOid')->willReturn(null);
        $identityGateway->method('findByProviderIssuerSubject')->willReturn(null);

        $userReadRepo = $this->createMock(UserReadRepositoryInterface::class);
        $userReadRepo->method('findByEmail')->willReturn(null);

        $userWriteRepo = $this->createMock(UserWriteRepositoryInterface::class);
        $userWriteRepo->method('create')->willReturn(0);

        $settingsGateway = $this->createMock(AuthSettingsGateway::class);
        $settingsGateway->method('normalizeTheme')->willReturn('auto');

        $tenantSsoService = $this->createMock(TenantSsoService::class);
        $tenantSsoService->method('microsoftProviderKey')->willReturn('microsoft');

        $service = $this->newService(
            userReadRepository: $userReadRepo,
            userWriteRepository: $userWriteRepo,
            settingsGateway: $settingsGateway,
            tenantSsoService: $tenantSsoService,
            externalIdentityGateway: $identityGateway
        );

        $result = $service->linkOrProvisionMicrosoftUser(self::BASE_TENANT, self::BASE_CLAIMS);

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

    // ---------------------------------------------------------------
    // Avatar sync
    // ---------------------------------------------------------------

    public function testAvatarSyncProcessesValidJpegBase64(): void
    {
        $identityGateway = $this->createMock(AuthExternalIdentityGateway::class);
        $identityGateway->method('findByProviderTidOid')->willReturn(['user_id' => 60]);

        $userReadRepo = $this->createMock(UserReadRepositoryInterface::class);
        $userReadRepo->expects($this->any())->method('find')->with(60)->willReturn(['id' => 60, 'active' => 1, 'uuid' => 'user-uuid-60']);

        $userTenantRepo = $this->createMock(UserTenantRepositoryInterface::class);
        $userTenantRepo->method('listTenantIdsByUserId')->willReturn([1]);

        $userWriteRepo = $this->createMock(UserWriteRepositoryInterface::class);
        $userWriteRepo->expects($this->once())
            ->method('updateProfileFieldsFromSso')
            ->with(60, $this->isArray())
            ->willReturn(true);

        $avatarService = $this->createMock(UserAvatarService::class);
        $avatarService->expects($this->any())->method('isValidUuid')->with('user-uuid-60')->willReturn(true);
        $avatarService->expects($this->once())
            ->method('saveBinary')
            ->with('user-uuid-60', $this->isString(), 'image/jpeg')
            ->willReturn(['ok' => true]);

        $tenantSsoService = $this->createMock(TenantSsoService::class);
        $tenantSsoService->method('microsoftProviderKey')->willReturn('microsoft');
        $tenantSsoService->method('normalizeProfileSyncFields')
            ->willReturn(['first_name', 'last_name', 'avatar']);
        $tenantSsoService->method('defaultProfileSyncFields')
            ->willReturn(['first_name', 'last_name']);

        $service = $this->newService(
            userReadRepository: $userReadRepo,
            userWriteRepository: $userWriteRepo,
            userTenantRepository: $userTenantRepo,
            avatarService: $avatarService,
            tenantSsoService: $tenantSsoService,
            externalIdentityGateway: $identityGateway
        );

        $claims = self::BASE_CLAIMS;
        $claims['sync_profile_on_login'] = true;
        $claims['sync_profile_fields'] = ['first_name', 'last_name', 'avatar'];
        $claims['graph_avatar_data_base64'] = base64_encode('fake-jpeg-binary-data');
        $claims['graph_avatar_mime'] = 'image/jpeg';

        $result = $service->linkOrProvisionMicrosoftUser(self::BASE_TENANT, $claims);

        $this->assertTrue($result['ok']);
    }

    public function testAvatarSyncSkipsInvalidMime(): void
    {
        $identityGateway = $this->createMock(AuthExternalIdentityGateway::class);
        $identityGateway->method('findByProviderTidOid')->willReturn(['user_id' => 61]);

        $userReadRepo = $this->createMock(UserReadRepositoryInterface::class);
        $userReadRepo->expects($this->any())->method('find')->with(61)->willReturn(['id' => 61, 'active' => 1, 'uuid' => 'user-uuid-61']);

        $userTenantRepo = $this->createMock(UserTenantRepositoryInterface::class);
        $userTenantRepo->method('listTenantIdsByUserId')->willReturn([1]);

        $userWriteRepo = $this->createMock(UserWriteRepositoryInterface::class);
        $userWriteRepo->expects($this->once())->method('updateProfileFieldsFromSso');

        $avatarService = $this->createMock(UserAvatarService::class);
        $avatarService->expects($this->never())->method('saveBinary');

        $tenantSsoService = $this->createMock(TenantSsoService::class);
        $tenantSsoService->method('microsoftProviderKey')->willReturn('microsoft');
        $tenantSsoService->method('normalizeProfileSyncFields')
            ->willReturn(['first_name', 'avatar']);
        $tenantSsoService->method('defaultProfileSyncFields')
            ->willReturn(['first_name', 'last_name']);

        $service = $this->newService(
            userReadRepository: $userReadRepo,
            userWriteRepository: $userWriteRepo,
            userTenantRepository: $userTenantRepo,
            avatarService: $avatarService,
            tenantSsoService: $tenantSsoService,
            externalIdentityGateway: $identityGateway
        );

        $claims = self::BASE_CLAIMS;
        $claims['sync_profile_on_login'] = true;
        $claims['sync_profile_fields'] = ['first_name', 'avatar'];
        $claims['graph_avatar_data_base64'] = base64_encode('some-data');
        $claims['graph_avatar_mime'] = 'application/pdf';

        $result = $service->linkOrProvisionMicrosoftUser(self::BASE_TENANT, $claims);

        $this->assertTrue($result['ok']);
    }

    public function testProfileSyncSkippedWhenFlagNotSet(): void
    {
        $identityGateway = $this->createMock(AuthExternalIdentityGateway::class);
        $identityGateway->method('findByProviderTidOid')->willReturn(['user_id' => 62]);

        $userReadRepo = $this->createMock(UserReadRepositoryInterface::class);
        $userReadRepo->expects($this->any())->method('find')->with(62)->willReturn(['id' => 62, 'active' => 1]);

        $userTenantRepo = $this->createMock(UserTenantRepositoryInterface::class);
        $userTenantRepo->method('listTenantIdsByUserId')->willReturn([1]);

        $userWriteRepo = $this->createMock(UserWriteRepositoryInterface::class);
        $userWriteRepo->expects($this->never())->method('updateProfileFieldsFromSso');

        $avatarService = $this->createMock(UserAvatarService::class);
        $avatarService->expects($this->never())->method('saveBinary');

        $tenantSsoService = $this->createMock(TenantSsoService::class);
        $tenantSsoService->method('microsoftProviderKey')->willReturn('microsoft');

        $service = $this->newService(
            userReadRepository: $userReadRepo,
            userWriteRepository: $userWriteRepo,
            userTenantRepository: $userTenantRepo,
            avatarService: $avatarService,
            tenantSsoService: $tenantSsoService,
            externalIdentityGateway: $identityGateway
        );

        // No sync_profile_on_login key
        $result = $service->linkOrProvisionMicrosoftUser(self::BASE_TENANT, self::BASE_CLAIMS);

        $this->assertTrue($result['ok']);
    }

    // ── LDAP user link tests ──────────────────────────────────────────

    public function testLinkOrProvisionLdapUserReturnsIdentityInvalidForEmptyHost(): void
    {
        $service = $this->newService();
        $result = $service->linkOrProvisionLdapUser(
            ['id' => 1],
            ['unique_id' => 'guid-123', 'dn' => 'CN=User,DC=example,DC=com', 'attributes' => []],
            ['host' => '']
        );
        $this->assertFalse($result['ok']);
        $this->assertSame('identity_invalid', $result['error']);
    }

    public function testLinkOrProvisionLdapUserReturnsIdentityInvalidForEmptyOid(): void
    {
        $service = $this->newService();
        $result = $service->linkOrProvisionLdapUser(
            ['id' => 1],
            ['unique_id' => '', 'dn' => '', 'attributes' => []],
            ['host' => 'ldap.example.com']
        );
        $this->assertFalse($result['ok']);
        $this->assertSame('identity_invalid', $result['error']);
    }

    public function testLinkOrProvisionLdapUserLinksExistingIdentity(): void
    {
        $identityGateway = $this->createMock(AuthExternalIdentityGateway::class);
        $identityGateway->method('findByProviderTidOid')
            ->willReturn(['user_id' => 42]);

        $userReadRepo = $this->createMock(UserReadRepositoryInterface::class);
        $userReadRepo->method('find')
            ->willReturn(['id' => 42, 'active' => 1]);

        $userTenantRepo = $this->createMock(UserTenantRepositoryInterface::class);
        $userTenantRepo->method('listTenantIdsByUserId')
            ->willReturn([1]);

        $tenantSsoService = $this->createMock(TenantSsoService::class);
        $tenantSsoService->method('ldapProviderKey')->willReturn('ldap');
        $tenantSsoService->method('normalizeLdapProfileSyncFields')->willReturn([]);
        $tenantSsoService->method('defaultProfileSyncFields')->willReturn([]);

        $service = $this->newService($userReadRepo, null, null, $userTenantRepo, null, $tenantSsoService, null, $identityGateway);
        $result = $service->linkOrProvisionLdapUser(
            ['id' => 1],
            ['unique_id' => 'guid-123', 'dn' => 'CN=User,DC=example,DC=com', 'attributes' => ['email' => 'test@example.com']],
            ['host' => 'ldap.example.com', 'sync_profile_on_login' => false]
        );

        $this->assertTrue($result['ok']);
        $this->assertSame(42, $result['user_id']);
        $this->assertFalse($result['created']);
    }

    public function testLinkOrProvisionLdapUserReturnsInactiveForDisabledUser(): void
    {
        $identityGateway = $this->createMock(AuthExternalIdentityGateway::class);
        $identityGateway->method('findByProviderTidOid')
            ->willReturn(['user_id' => 42]);

        $userReadRepo = $this->createMock(UserReadRepositoryInterface::class);
        $userReadRepo->method('find')
            ->willReturn(['id' => 42, 'active' => 0]);

        $tenantSsoService = $this->createMock(TenantSsoService::class);
        $tenantSsoService->method('ldapProviderKey')->willReturn('ldap');

        $service = $this->newService($userReadRepo, null, null, null, null, $tenantSsoService, null, $identityGateway);
        $result = $service->linkOrProvisionLdapUser(
            ['id' => 1],
            ['unique_id' => 'guid-123', 'dn' => 'CN=User,DC=example,DC=com', 'attributes' => []],
            ['host' => 'ldap.example.com']
        );

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

    public function testLinkOrProvisionLdapUserProvisionsNewUser(): void
    {
        $identityGateway = $this->createMock(AuthExternalIdentityGateway::class);
        $identityGateway->method('findByProviderTidOid')->willReturn(null);

        $userReadRepo = $this->createMock(UserReadRepositoryInterface::class);
        $userReadRepo->method('findByEmail')->willReturn(null);

        $userWriteRepo = $this->createMock(UserWriteRepositoryInterface::class);
        $userWriteRepo->method('create')->willReturn(99);
        $userWriteRepo->method('setEmailVerified')->willReturn(true);
        $userWriteRepo->method('updateProfileFieldsFromSso')->willReturn(true);

        $userAssignment = $this->createMock(UserAssignmentService::class);

        $settingsGateway = $this->createMock(AuthSettingsGateway::class);
        $settingsGateway->method('getDefaultRoleId')->willReturn(0);
        $settingsGateway->method('getDefaultDepartmentId')->willReturn(0);
        $settingsGateway->method('normalizeTheme')->willReturn('light');

        $tenantSsoService = $this->createMock(TenantSsoService::class);
        $tenantSsoService->method('ldapProviderKey')->willReturn('ldap');
        $tenantSsoService->method('normalizeLdapProfileSyncFields')->willReturn(['first_name', 'last_name']);
        $tenantSsoService->method('defaultProfileSyncFields')->willReturn(['first_name', 'last_name']);

        $service = $this->newService($userReadRepo, $userWriteRepo, $userAssignment, null, $settingsGateway, $tenantSsoService, null, $identityGateway);
        $result = $service->linkOrProvisionLdapUser(
            ['id' => 1],
            [
                'unique_id' => 'guid-new',
                'dn' => 'CN=New User,DC=example,DC=com',
                'attributes' => [
                    'first_name' => 'New',
                    'last_name' => 'User',
                    'email' => 'new@example.com',
                ],
            ],
            ['host' => 'ldap.example.com', 'sync_profile_on_login' => true, 'sync_profile_fields' => ['first_name', 'last_name']]
        );

        $this->assertTrue($result['ok']);
        $this->assertSame(99, $result['user_id']);
        $this->assertTrue($result['created']);
    }

    public function testLinkOrProvisionLdapUserReturnsEmailMissingWhenNoEmail(): void
    {
        $identityGateway = $this->createMock(AuthExternalIdentityGateway::class);
        $identityGateway->method('findByProviderTidOid')->willReturn(null);

        $userReadRepo = $this->createMock(UserReadRepositoryInterface::class);

        $tenantSsoService = $this->createMock(TenantSsoService::class);
        $tenantSsoService->method('ldapProviderKey')->willReturn('ldap');

        $service = $this->newService($userReadRepo, null, null, null, null, $tenantSsoService, null, $identityGateway);
        $result = $service->linkOrProvisionLdapUser(
            ['id' => 1],
            ['unique_id' => 'guid-123', 'dn' => 'CN=User,DC=example,DC=com', 'attributes' => ['email' => '']],
            ['host' => 'ldap.example.com']
        );

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

    // ---------------------------------------------------------------
    // Factory helper
    // ---------------------------------------------------------------

    private function newService(
        ?UserReadRepositoryInterface $userReadRepository = null,
        ?UserWriteRepositoryInterface $userWriteRepository = null,
        ?UserAssignmentService $userAssignmentService = null,
        ?UserTenantRepositoryInterface $userTenantRepository = null,
        ?AuthSettingsGateway $settingsGateway = null,
        ?TenantSsoService $tenantSsoService = null,
        ?UserAvatarService $avatarService = null,
        ?AuthExternalIdentityGateway $externalIdentityGateway = null
    ): SsoUserLinkService {
        return new SsoUserLinkService(
            $userReadRepository ?? $this->createMock(UserReadRepositoryInterface::class),
            $userWriteRepository ?? $this->createMock(UserWriteRepositoryInterface::class),
            $userAssignmentService ?? $this->createMock(UserAssignmentService::class),
            $userTenantRepository ?? $this->createMock(UserTenantRepositoryInterface::class),
            $settingsGateway ?? $this->createMock(AuthSettingsGateway::class),
            $tenantSsoService ?? $this->createMock(TenantSsoService::class),
            $avatarService ?? $this->createMock(UserAvatarService::class),
            $externalIdentityGateway ?? $this->createMock(AuthExternalIdentityGateway::class)
        );
    }
}
