package org.raddatz.familienarchiv.user;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.user.CreateInviteRequest;
import org.raddatz.familienarchiv.user.RegisterRequest;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.user.AppUser;
import org.raddatz.familienarchiv.user.InviteToken;
import org.raddatz.familienarchiv.user.UserGroup;
import org.raddatz.familienarchiv.user.InviteTokenRepository;

import java.time.LocalDateTime;
import java.util.*;

import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;

@ExtendWith(MockitoExtension.class)
class InviteServiceTest {

    @Mock InviteTokenRepository inviteTokenRepository;
    @Mock UserService userService;
    @InjectMocks InviteService inviteService;

    private AppUser admin;

    @BeforeEach
    void setUp() {
        admin = AppUser.builder().id(UUID.randomUUID()).email("admin@test.com").build();
    }

    // ─── generateCode ────────────────────────────────────────────────────────────

    @Test
    void generateCode_produces10CharUppercaseAlphanumeric() {
        when(inviteTokenRepository.findByCode(anyString())).thenReturn(Optional.empty());
        String code = inviteService.generateCode();
        assertThat(code).hasSize(10).matches("[A-Z0-9]{10}");
    }

    @Test
    void generateCode_retriesOnCollision() {
        when(inviteTokenRepository.findByCode(anyString()))
                .thenReturn(Optional.of(InviteToken.builder().code("AAAAAAAAAA").build()))
                .thenReturn(Optional.empty());
        String code = inviteService.generateCode();
        assertThat(code).hasSize(10);
        verify(inviteTokenRepository, times(2)).findByCode(anyString());
    }

    @Test
    void generateCode_throwsInternalError_afterMaxAttempts() {
        when(inviteTokenRepository.findByCode(anyString()))
                .thenReturn(Optional.of(InviteToken.builder().code("AAAAAAAAAA").build()));
        assertThatThrownBy(() -> inviteService.generateCode())
                .isInstanceOf(DomainException.class)
                .extracting(e -> ((DomainException) e).getCode())
                .isEqualTo(ErrorCode.INTERNAL_ERROR);
    }

    // ─── validateCode ─────────────────────────────────────────────────────────

    @Test
    void validateCode_throwsNotFound_whenCodeUnknown() {
        when(inviteTokenRepository.findByCode("UNKNOWN123")).thenReturn(Optional.empty());
        assertThatThrownBy(() -> inviteService.validateCode("UNKNOWN123"))
                .isInstanceOf(DomainException.class)
                .extracting(e -> ((DomainException) e).getCode())
                .isEqualTo(ErrorCode.INVITE_NOT_FOUND);
    }

    @Test
    void validateCode_throwsRevoked_whenTokenIsRevoked() {
        InviteToken token = InviteToken.builder().code("ABCDE12345").revoked(true).build();
        when(inviteTokenRepository.findByCode("ABCDE12345")).thenReturn(Optional.of(token));
        assertThatThrownBy(() -> inviteService.validateCode("ABCDE12345"))
                .isInstanceOf(DomainException.class)
                .extracting(e -> ((DomainException) e).getCode())
                .isEqualTo(ErrorCode.INVITE_REVOKED);
    }

    @Test
    void validateCode_throwsExpired_whenTokenIsPastExpiryDate() {
        InviteToken token = InviteToken.builder().code("ABCDE12345")
                .expiresAt(LocalDateTime.now().minusHours(1))
                .build();
        when(inviteTokenRepository.findByCode("ABCDE12345")).thenReturn(Optional.of(token));
        assertThatThrownBy(() -> inviteService.validateCode("ABCDE12345"))
                .isInstanceOf(DomainException.class)
                .extracting(e -> ((DomainException) e).getCode())
                .isEqualTo(ErrorCode.INVITE_EXPIRED);
    }

    @Test
    void validateCode_throwsExhausted_whenUseCountMeetsMaxUses() {
        InviteToken token = InviteToken.builder().code("ABCDE12345").maxUses(1).useCount(1).build();
        when(inviteTokenRepository.findByCode("ABCDE12345")).thenReturn(Optional.of(token));
        assertThatThrownBy(() -> inviteService.validateCode("ABCDE12345"))
                .isInstanceOf(DomainException.class)
                .extracting(e -> ((DomainException) e).getCode())
                .isEqualTo(ErrorCode.INVITE_EXHAUSTED);
    }

    @Test
    void validateCode_returnsToken_whenValid() {
        InviteToken token = InviteToken.builder().code("ABCDE12345").build();
        when(inviteTokenRepository.findByCode("ABCDE12345")).thenReturn(Optional.of(token));
        InviteToken result = inviteService.validateCode("ABCDE12345");
        assertThat(result.getCode()).isEqualTo("ABCDE12345");
    }

    @Test
    void validateCode_acceptsUnlimitedInvite_afterManyUses() {
        InviteToken token = InviteToken.builder().code("ABCDE12345").maxUses(null).useCount(100).build();
        when(inviteTokenRepository.findByCode("ABCDE12345")).thenReturn(Optional.of(token));
        assertThatNoException().isThrownBy(() -> inviteService.validateCode("ABCDE12345"));
    }

    // ─── createInvite ────────────────────────────────────────────────────────────

    @Test
    void createInvite_savesTokenWithGeneratedCode() {
        when(inviteTokenRepository.findByCode(anyString())).thenReturn(Optional.empty());
        when(inviteTokenRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));

        CreateInviteRequest req = new CreateInviteRequest();
        req.setLabel("Für Oma Helga");
        req.setMaxUses(1);

        InviteToken result = inviteService.createInvite(req, admin);

        assertThat(result.getLabel()).isEqualTo("Für Oma Helga");
        assertThat(result.getMaxUses()).isEqualTo(1);
        assertThat(result.getCode()).hasSize(10);
        assertThat(result.getCreatedBy()).isEqualTo(admin);
    }

    @Test
    void createInvite_assignsGroups_whenGroupIdsProvided() {
        UserGroup g = UserGroup.builder().id(UUID.randomUUID()).name("Familie").build();
        when(inviteTokenRepository.findByCode(anyString())).thenReturn(Optional.empty());
        when(userService.findGroupsByIds(anyList())).thenReturn(List.of(g));
        when(inviteTokenRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));

        CreateInviteRequest req = new CreateInviteRequest();
        req.setGroupIds(List.of(g.getId()));

        InviteToken result = inviteService.createInvite(req, admin);
        assertThat(result.getGroupIds()).contains(g.getId());
    }

    @Test
    void createInvite_throwsGroupNotFound_whenSubmittedGroupIdDoesNotExist() {
        UUID unknownGroupId = UUID.randomUUID();
        when(userService.findGroupsByIds(anyList())).thenReturn(List.of());

        CreateInviteRequest req = new CreateInviteRequest();
        req.setGroupIds(List.of(unknownGroupId));

        assertThatThrownBy(() -> inviteService.createInvite(req, admin))
                .isInstanceOf(DomainException.class)
                .extracting(e -> ((DomainException) e).getCode())
                .isEqualTo(ErrorCode.GROUP_NOT_FOUND);
    }

    @Test
    void createInvite_doesNotThrowGroupNotFound_whenDuplicateGroupIdsSubmitted() {
        UUID groupId = UUID.randomUUID();
        UserGroup group = UserGroup.builder().id(groupId).name("Familie").build();
        when(inviteTokenRepository.findByCode(anyString())).thenReturn(Optional.empty());
        when(userService.findGroupsByIds(anyList())).thenReturn(List.of(group));
        when(inviteTokenRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));

        CreateInviteRequest req = new CreateInviteRequest();
        req.setGroupIds(List.of(groupId, groupId)); // same UUID submitted twice

        // before deduplication: size(groups)==1 != size(submitted)==2 → false GROUP_NOT_FOUND
        assertThatCode(() -> inviteService.createInvite(req, admin)).doesNotThrowAnyException();
    }

    // ─── redeemInvite ─────────────────────────────────────────────────────────

    @Test
    void redeemInvite_createsUserAndIncrementsUseCount() {
        InviteToken token = InviteToken.builder()
                .code("ABCDE12345").maxUses(2).useCount(0)
                .groupIds(new HashSet<>())
                .build();
        when(inviteTokenRepository.findByCodeForUpdate("ABCDE12345")).thenReturn(Optional.of(token));
        AppUser created = AppUser.builder().id(UUID.randomUUID()).email("new@test.com").build();
        when(userService.createUser(eq("new@test.com"), eq("password123"), eq("Max"), eq("Muster"), any()))
                .thenReturn(created);
        when(inviteTokenRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));

        RegisterRequest req = new RegisterRequest();
        req.setCode("ABCDE12345");
        req.setEmail("new@test.com");
        req.setPassword("password123");
        req.setFirstName("Max");
        req.setLastName("Muster");

        AppUser result = inviteService.redeemInvite(req);

        assertThat(result.getEmail()).isEqualTo("new@test.com");
        assertThat(token.getUseCount()).isEqualTo(1);
        verify(userService).createUser(eq("new@test.com"), eq("password123"), eq("Max"), eq("Muster"), any());
        verify(inviteTokenRepository).save(token);
    }

    @Test
    void redeemInvite_throwsBadRequest_whenPasswordTooShort() {
        InviteToken token = InviteToken.builder().code("ABCDE12345").groupIds(new HashSet<>()).build();
        when(inviteTokenRepository.findByCodeForUpdate("ABCDE12345")).thenReturn(Optional.of(token));

        RegisterRequest req = new RegisterRequest();
        req.setCode("ABCDE12345");
        req.setEmail("new@test.com");
        req.setPassword("short");

        assertThatThrownBy(() -> inviteService.redeemInvite(req))
                .isInstanceOf(DomainException.class)
                .extracting(e -> ((DomainException) e).getCode())
                .isEqualTo(ErrorCode.VALIDATION_ERROR);
    }

    @Test
    void redeemInvite_throwsConflict_whenEmailAlreadyInUse() {
        InviteToken token = InviteToken.builder().code("ABCDE12345").groupIds(new HashSet<>()).build();
        when(inviteTokenRepository.findByCodeForUpdate("ABCDE12345")).thenReturn(Optional.of(token));
        when(userService.createUser(eq("dupe@test.com"), any(), any(), any(), any()))
                .thenThrow(DomainException.conflict(ErrorCode.EMAIL_ALREADY_IN_USE, "Email already registered"));

        RegisterRequest req = new RegisterRequest();
        req.setCode("ABCDE12345");
        req.setEmail("dupe@test.com");
        req.setPassword("password123");

        assertThatThrownBy(() -> inviteService.redeemInvite(req))
                .isInstanceOf(DomainException.class)
                .extracting(e -> ((DomainException) e).getCode())
                .isEqualTo(ErrorCode.EMAIL_ALREADY_IN_USE);
    }

    @Test
    void redeemInvite_passesGroupIdsFromTokenToUserService() {
        UUID groupId = UUID.randomUUID();
        InviteToken token = InviteToken.builder()
                .code("ABCDE12345")
                .groupIds(new HashSet<>(Set.of(groupId)))
                .build();
        when(inviteTokenRepository.findByCodeForUpdate("ABCDE12345")).thenReturn(Optional.of(token));
        AppUser created = AppUser.builder().id(UUID.randomUUID()).email("new@test.com").build();
        when(userService.createUser(any(), any(), any(), any(), eq(Set.of(groupId)))).thenReturn(created);
        when(inviteTokenRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));

        RegisterRequest req = new RegisterRequest();
        req.setCode("ABCDE12345");
        req.setEmail("new@test.com");
        req.setPassword("password123");

        inviteService.redeemInvite(req);

        verify(userService).createUser(any(), any(), any(), any(), eq(Set.of(groupId)));
    }

    // ─── revokeInvite ─────────────────────────────────────────────────────────

    @Test
    void revokeInvite_setsRevokedTrue() {
        UUID id = UUID.randomUUID();
        InviteToken token = InviteToken.builder().id(id).code("ABCDE12345").build();
        when(inviteTokenRepository.findById(id)).thenReturn(Optional.of(token));
        when(inviteTokenRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));

        inviteService.revokeInvite(id);

        assertThat(token.isRevoked()).isTrue();
        verify(inviteTokenRepository).save(token);
    }

    @Test
    void revokeInvite_throwsNotFound_whenIdUnknown() {
        UUID id = UUID.randomUUID();
        when(inviteTokenRepository.findById(id)).thenReturn(Optional.empty());
        assertThatThrownBy(() -> inviteService.revokeInvite(id))
                .isInstanceOf(DomainException.class)
                .extracting(e -> ((DomainException) e).getCode())
                .isEqualTo(ErrorCode.INVITE_NOT_FOUND);
    }
}
