package com.codebyte.api.auth.service;

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

import com.codebyte.api.auth.domain.RefreshToken;
import com.codebyte.api.auth.domain.User;
import com.codebyte.api.auth.repository.RefreshTokenRepository;
import com.codebyte.api.common.util.HashUtil;
import com.codebyte.api.config.AppProperties;
import java.time.Instant;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

@ExtendWith(MockitoExtension.class)
class RefreshTokenServiceTest {

    @Mock private RefreshTokenRepository repository;

    private final AppProperties properties = com.codebyte.api.support.TestAppProperties.defaults();

    private final User user = new User("user@example.com", "hash");

    private RefreshTokenService service() {
        return new RefreshTokenService(repository, properties);
    }

    @Test
    void should_issue_hashed_refresh_token() {
        when(repository.save(any())).thenAnswer(inv -> inv.getArgument(0));

        String raw = service().issue(user, "agent", "1.2.3.4");

        ArgumentCaptor<RefreshToken> captor = ArgumentCaptor.forClass(RefreshToken.class);
        verify(repository).save(captor.capture());
        RefreshToken saved = captor.getValue();
        assertThat(saved.getTokenHash()).isEqualTo(HashUtil.sha256Hex(raw));
        assertThat(saved.getUserAgent()).isEqualTo("agent");
        assertThat(saved.getIp()).isEqualTo("1.2.3.4");
        assertThat(saved.getExpiresAt()).isAfter(Instant.now());
    }

    @Test
    void should_rotate_active_token() {
        RefreshToken current =
                new RefreshToken(user, "hash", Instant.now().plusSeconds(600), "a", "ip");
        when(repository.findByTokenHash(anyString())).thenReturn(Optional.of(current));
        when(repository.save(any())).thenAnswer(inv -> inv.getArgument(0));

        RefreshTokenService.Rotation rotation = service().rotate("old-raw", "a", "ip");

        assertThat(rotation.user()).isSameAs(user);
        assertThat(rotation.refreshToken()).isNotBlank();
        assertThat(current.getRevokedAt()).isNotNull();
        assertThat(current.getReplacedBy()).isNotNull();
    }

    @Test
    void should_reject_unknown_refresh_token() {
        when(repository.findByTokenHash(anyString())).thenReturn(Optional.empty());

        assertThatThrownBy(() -> service().rotate("nope", "a", "ip"))
                .isInstanceOf(InvalidRefreshTokenException.class);
    }

    @Test
    void should_reject_expired_refresh_token() {
        RefreshToken expired =
                new RefreshToken(user, "hash", Instant.now().minusSeconds(1), "a", "ip");
        when(repository.findByTokenHash(anyString())).thenReturn(Optional.of(expired));

        assertThatThrownBy(() -> service().rotate("old", "a", "ip"))
                .isInstanceOf(InvalidRefreshTokenException.class);
    }

    @Test
    void should_detect_reuse_and_revoke_chain_for_rotated_token() {
        RefreshToken rotated =
                new RefreshToken(user, "hash", Instant.now().plusSeconds(600), "a", "ip");
        rotated.setRevokedAt(Instant.now().minusSeconds(10));
        rotated.setReplacedBy(
                new RefreshToken(user, "next", Instant.now().plusSeconds(600), "a", "ip"));
        when(repository.findByTokenHash(anyString())).thenReturn(Optional.of(rotated));

        assertThatThrownBy(() -> service().rotate("reused", "a", "ip"))
                .isInstanceOf(TokenReuseDetectedException.class);
        verify(repository).revokeAllActiveForUser(eq(user), any(Instant.class));
        verify(repository, never()).save(any());
    }

    @Test
    void should_reject_revoked_but_not_rotated_token_without_chain_revocation() {
        RefreshToken loggedOut =
                new RefreshToken(user, "hash", Instant.now().plusSeconds(600), "a", "ip");
        loggedOut.setRevokedAt(Instant.now().minusSeconds(10)); // revoked, replacedBy == null
        when(repository.findByTokenHash(anyString())).thenReturn(Optional.of(loggedOut));

        assertThatThrownBy(() -> service().rotate("x", "a", "ip"))
                .isInstanceOf(InvalidRefreshTokenException.class);
        verify(repository, never()).revokeAllActiveForUser(any(), any());
    }

    @Test
    void should_revoke_single_active_token_on_logout() {
        RefreshToken active =
                new RefreshToken(user, "hash", Instant.now().plusSeconds(600), "a", "ip");
        when(repository.findByTokenHash(anyString())).thenReturn(Optional.of(active));

        service().revoke("raw");

        assertThat(active.getRevokedAt()).isNotNull();
    }

    @Test
    void should_be_noop_when_logging_out_unknown_token() {
        when(repository.findByTokenHash(anyString())).thenReturn(Optional.empty());

        service().revoke("raw"); // no exception

        verify(repository, never()).save(any());
    }

    @Test
    void should_revoke_all_for_user() {
        service().revokeAll(user);

        verify(repository).revokeAllActiveForUser(eq(user), any(Instant.class));
    }
}
