package com.magistr.app.config.auth;

import com.magistr.app.model.AuthLoginAttemptAudit;
import com.magistr.app.model.AuthLoginRateLimit;
import com.magistr.app.model.User;
import com.magistr.app.repository.AuthLoginAttemptAuditRepository;
import com.magistr.app.repository.AuthLoginRateLimitRepository;
import com.magistr.app.repository.UserRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Optional;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

class LoginRateLimitServiceTest {

    private static final Instant NOW = Instant.parse("2026-07-19T09:00:00Z");

    private AuthLoginRateLimitRepository rateLimitRepository;
    private AuthLoginAttemptAuditRepository auditRepository;
    private UserRepository userRepository;
    private BCryptPasswordEncoder passwordEncoder;
    private LoginSecurityProperties properties;

    @BeforeEach
    void setUp() {
        rateLimitRepository = mock(AuthLoginRateLimitRepository.class);
        auditRepository = mock(AuthLoginAttemptAuditRepository.class);
        userRepository = mock(UserRepository.class);
        passwordEncoder = new BCryptPasswordEncoder(4);
        properties = new LoginSecurityProperties();
    }

    @Test
    void fifthFailureBlocksKeyAndWritesAuditWithoutPassword() {
        AuthLoginRateLimit state = state(4, NOW, null);
        when(rateLimitRepository.findForUpdate("tenant", "admin", "198.51.100.5"))
                .thenReturn(Optional.of(state));
        when(userRepository.findByUsername("Admin")).thenReturn(Optional.empty());
        LoginRateLimitService service = service();

        LoginRateLimitService.AuthenticationDecision decision = service.authenticate(
                "tenant", "Admin", "секретный-пароль", "198.51.100.5"
        );

        assertThat(decision.status())
                .isEqualTo(LoginRateLimitService.AuthenticationDecision.Status.BLOCKED);
        assertThat(decision.retryAfterSeconds()).isEqualTo(60);
        assertThat(state.getFailureCount()).isEqualTo(5);
        assertThat(state.getBlockedUntil()).isEqualTo(NOW.plusSeconds(60));

        ArgumentCaptor<AuthLoginAttemptAudit> auditCaptor =
                ArgumentCaptor.forClass(AuthLoginAttemptAudit.class);
        verify(auditRepository).save(auditCaptor.capture());
        assertThat(auditCaptor.getValue().getOutcome()).isEqualTo("BLOCKED");
        assertThat(auditCaptor.getValue().getRetryAfterSeconds()).isEqualTo(60);
        assertThat(auditCaptor.getValue().getUsernameNormalized()).isEqualTo("admin");
    }

    @Test
    void activeBlockDoesNotQueryUserAndReturnsRemainingDelay() {
        AuthLoginRateLimit state = state(5, NOW, NOW.plusSeconds(37));
        when(rateLimitRepository.findForUpdate("tenant", "admin", "198.51.100.5"))
                .thenReturn(Optional.of(state));
        LoginRateLimitService service = service();

        LoginRateLimitService.AuthenticationDecision decision = service.authenticate(
                "tenant", "admin", "любой", "198.51.100.5"
        );

        assertThat(decision.status())
                .isEqualTo(LoginRateLimitService.AuthenticationDecision.Status.BLOCKED);
        assertThat(decision.retryAfterSeconds()).isEqualTo(37);
        verify(userRepository, never()).findByUsername(any());
    }

    @Test
    void archivedUserIsRejectedLikeUnknownUser() {
        AuthLoginRateLimit state = state(0, NOW, null);
        when(rateLimitRepository.findForUpdate("tenant", "archived", "198.51.100.5"))
                .thenReturn(Optional.of(state));
        User user = new User();
        user.setPassword(passwordEncoder.encode("верный-пароль"));
        user.archive("Тест");
        when(userRepository.findByUsername("archived")).thenReturn(Optional.of(user));
        LoginRateLimitService service = service();

        LoginRateLimitService.AuthenticationDecision decision = service.authenticate(
                "tenant", "archived", "верный-пароль", "198.51.100.5"
        );

        assertThat(decision.status())
                .isEqualTo(LoginRateLimitService.AuthenticationDecision.Status.REJECTED);
        assertThat(state.getFailureCount()).isEqualTo(1);
    }

    @Test
    void successfulLoginRemovesPreviousFailureState() {
        AuthLoginRateLimit state = state(2, NOW, null);
        when(rateLimitRepository.findForUpdate("tenant", "admin", "198.51.100.5"))
                .thenReturn(Optional.of(state));
        User user = new User();
        user.setPassword(passwordEncoder.encode("верный-пароль"));
        when(userRepository.findByUsername("admin")).thenReturn(Optional.of(user));
        LoginRateLimitService service = service();

        LoginRateLimitService.AuthenticationDecision decision = service.authenticate(
                "tenant", "admin", "верный-пароль", "198.51.100.5"
        );

        assertThat(decision.status())
                .isEqualTo(LoginRateLimitService.AuthenticationDecision.Status.AUTHENTICATED);
        assertThat(decision.user()).isSameAs(user);
        verify(rateLimitRepository).delete(state);
        verify(auditRepository, never()).save(any());
    }

    private LoginRateLimitService service() {
        return new LoginRateLimitService(
                rateLimitRepository,
                auditRepository,
                userRepository,
                passwordEncoder,
                properties,
                Clock.fixed(NOW, ZoneOffset.UTC)
        );
    }

    private AuthLoginRateLimit state(int failureCount,
                                     Instant windowStartedAt,
                                     Instant blockedUntil) {
        AuthLoginRateLimit state = new AuthLoginRateLimit();
        state.setFailureCount(failureCount);
        state.setWindowStartedAt(windowStartedAt);
        state.setBlockedUntil(blockedUntil);
        return state;
    }
}
