package org.raddatz.familienarchiv.auth;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatNoException;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class LoginRateLimiterTest {

    private LoginRateLimiter rateLimiter;

    @BeforeEach
    void setUp() {
        RateLimitProperties props = new RateLimitProperties();
        props.setMaxAttemptsPerIpEmail(10);
        props.setMaxAttemptsPerIp(20);
        props.setWindowMinutes(15);
        rateLimiter = new LoginRateLimiter(props);
    }

    @Test
    void tenth_attempt_from_same_ip_email_succeeds() {
        for (int i = 0; i < 10; i++) {
            assertThatNoException().isThrownBy(
                () -> rateLimiter.checkAndConsume("1.2.3.4", "user@example.com"));
        }
    }

    @Test
    void eleventh_attempt_from_same_ip_email_throws_TOO_MANY_LOGIN_ATTEMPTS() {
        for (int i = 0; i < 10; i++) {
            rateLimiter.checkAndConsume("1.2.3.4", "user@example.com");
        }

        assertThatThrownBy(() -> rateLimiter.checkAndConsume("1.2.3.4", "user@example.com"))
            .isInstanceOf(DomainException.class)
            .satisfies(ex -> assertThat(((DomainException) ex).getCode())
                .isEqualTo(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS));
    }

    @Test
    void blocked_attempt_carries_retry_after_seconds_equal_to_window_duration() {
        for (int i = 0; i < 10; i++) {
            rateLimiter.checkAndConsume("1.2.3.4", "user@example.com");
        }

        assertThatThrownBy(() -> rateLimiter.checkAndConsume("1.2.3.4", "user@example.com"))
            .isInstanceOf(DomainException.class)
            .satisfies(ex -> assertThat(((DomainException) ex).getRetryAfterSeconds())
                .isEqualTo(15 * 60L));  // windowMinutes=15 → 900 seconds
    }

    @Test
    void success_after_10_failures_resets_ip_email_bucket() {
        for (int i = 0; i < 10; i++) {
            rateLimiter.checkAndConsume("1.2.3.4", "user@example.com");
        }

        rateLimiter.invalidateOnSuccess("1.2.3.4", "user@example.com");

        assertThatNoException().isThrownBy(
            () -> rateLimiter.checkAndConsume("1.2.3.4", "user@example.com"));
    }

    @Test
    void twentyfirst_attempt_from_same_ip_across_different_emails_throws() {
        for (int i = 0; i < 20; i++) {
            rateLimiter.checkAndConsume("1.2.3.4", "user" + i + "@example.com");
        }

        assertThatThrownBy(() -> rateLimiter.checkAndConsume("1.2.3.4", "attacker@example.com"))
            .isInstanceOf(DomainException.class)
            .satisfies(ex -> assertThat(((DomainException) ex).getCode())
                .isEqualTo(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS));
    }

    @Test
    void different_email_from_same_ip_not_blocked_by_sibling_email_exhaustion() {
        for (int i = 0; i < 10; i++) {
            rateLimiter.checkAndConsume("1.2.3.4", "user@example.com");
        }

        assertThatThrownBy(() -> rateLimiter.checkAndConsume("1.2.3.4", "user@example.com"))
            .isInstanceOf(DomainException.class);

        assertThatNoException().isThrownBy(
            () -> rateLimiter.checkAndConsume("1.2.3.4", "other@example.com"));
    }

    @Test
    void email_lookup_is_case_insensitive_so_mixed_case_shares_the_same_bucket() {
        for (int i = 0; i < 10; i++) {
            rateLimiter.checkAndConsume("1.2.3.4", "User@Example.COM");
        }

        assertThatThrownBy(() -> rateLimiter.checkAndConsume("1.2.3.4", "user@example.com"))
            .isInstanceOf(DomainException.class)
            .satisfies(ex -> assertThat(((DomainException) ex).getCode())
                .isEqualTo(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS));
    }

    @Test
    void invalidateOnSuccess_is_case_insensitive_so_mixed_case_clears_the_bucket() {
        for (int i = 0; i < 10; i++) {
            rateLimiter.checkAndConsume("1.2.3.4", "user@example.com");
        }

        rateLimiter.invalidateOnSuccess("1.2.3.4", "User@Example.COM");

        assertThatNoException().isThrownBy(
            () -> rateLimiter.checkAndConsume("1.2.3.4", "user@example.com"));
    }

    @Test
    void ip_exhaustion_does_not_consume_ipEmail_tokens_for_blocked_attempts() {
        // Use a tighter limiter so the phantom-consumption effect is observable.
        // ipEmail=3, IP=3: exhausting IP via one email burns the other email's quota with the old code.
        RateLimitProperties props = new RateLimitProperties();
        props.setMaxAttemptsPerIpEmail(3);
        props.setMaxAttemptsPerIp(3);
        props.setWindowMinutes(15);
        LoginRateLimiter tightLimiter = new LoginRateLimiter(props);

        // Exhaust the per-IP bucket using "user@"
        for (int i = 0; i < 3; i++) {
            tightLimiter.checkAndConsume("1.2.3.4", "user@example.com");
        }

        // Three blocked attempts for "target@" while IP is exhausted
        for (int i = 0; i < 3; i++) {
            assertThatThrownBy(() -> tightLimiter.checkAndConsume("1.2.3.4", "target@example.com"))
                .isInstanceOf(DomainException.class);
        }

        // A successful login for "user@" resets the IP bucket but NOT target@'s ipEmail bucket
        tightLimiter.invalidateOnSuccess("1.2.3.4", "user@example.com");

        // After IP reset: "target@" must NOT be blocked by an exhausted ipEmail bucket.
        // With the old code, 3 blocked attempts burned all 3 ipEmail tokens → blocked here.
        // With the fix, tokens are refunded on each blocked attempt → still has capacity.
        assertThatNoException().isThrownBy(
            () -> tightLimiter.checkAndConsume("1.2.3.4", "target@example.com"));
    }
}
