package com.mosquito.project.service;

import com.mosquito.project.config.AppConfig;
import com.mosquito.project.domain.Activity;
import com.mosquito.project.domain.MultiLevelRewardRule;
import com.mosquito.project.domain.Reward;
import com.mosquito.project.domain.RewardMode;
import com.mosquito.project.domain.RewardTier;
import com.mosquito.project.domain.User;
import com.mosquito.project.dto.ActivityStatsResponse;
import com.mosquito.project.dto.CreateApiKeyRequest;
import com.mosquito.project.exception.ActivityNotFoundException;
import com.mosquito.project.exception.FileUploadException;
import com.mosquito.project.exception.InvalidActivityDataException;
import com.mosquito.project.exception.InvalidApiKeyException;
import com.mosquito.project.exception.UserNotAuthorizedForActivityException;
import com.mosquito.project.persistence.entity.ApiKeyEntity;
import com.mosquito.project.persistence.entity.DailyActivityStatsEntity;
import com.mosquito.project.persistence.entity.UserInviteEntity;
import com.mosquito.project.persistence.repository.ActivityRepository;
import com.mosquito.project.persistence.repository.ApiKeyRepository;
import com.mosquito.project.persistence.repository.DailyActivityStatsRepository;
import com.mosquito.project.persistence.repository.UserInviteRepository;
import org.junit.jupiter.api.BeforeEach;
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;
import org.springframework.mock.web.MockMultipartFile;

import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Arrays;
import java.util.Base64;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;

import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
class ActivityServiceCoverageTest {

    @Mock
    private ActivityRepository activityRepository;

    @Mock
    private ApiKeyRepository apiKeyRepository;

    @Mock
    private DailyActivityStatsRepository dailyActivityStatsRepository;

    @Mock
    private UserInviteRepository userInviteRepository;

    @Mock
    private ApiKeyEncryptionService encryptionService;

    private ActivityService activityService;

    @BeforeEach
    void setUp() {
        DelayProvider delayProvider = millis -> { };
        activityService = new ActivityService(
            delayProvider,
            activityRepository,
            apiKeyRepository,
            dailyActivityStatsRepository,
            userInviteRepository,
            encryptionService,
            new AppConfig()
        );
    }

    @Test
    void accessActivity_shouldReject_whenNotInTargetUsers() {
        Activity activity = new Activity();
        activity.setTargetUserIds(Set.of(1L, 2L));
        User user = new User(3L, "user");

        assertThrows(UserNotAuthorizedForActivityException.class, () -> activityService.accessActivity(activity, user));
    }

    @Test
    void accessActivity_shouldAllow_whenTargetUsersEmpty() {
        Activity activity = new Activity();
        activity.setTargetUserIds(Set.of());
        User user = new User(3L, "user");

        assertDoesNotThrow(() -> activityService.accessActivity(activity, user));
    }

    @Test
    void uploadCustomizationImage_shouldRejectLargeFile() {
        byte[] payload = new byte[31 * 1024 * 1024];
        MockMultipartFile file = new MockMultipartFile("file", "big.jpg", "image/jpeg", payload);

        assertThrows(FileUploadException.class, () -> activityService.uploadCustomizationImage(1L, file));
    }

    @Test
    void uploadCustomizationImage_shouldRejectUnsupportedType() {
        MockMultipartFile file = new MockMultipartFile("file", "note.txt", "text/plain", "hello".getBytes());

        assertThrows(FileUploadException.class, () -> activityService.uploadCustomizationImage(1L, file));
    }

    @Test
    void uploadCustomizationImage_shouldAllowValidType() {
        MockMultipartFile file = new MockMultipartFile("file", "ok.png", "image/png", "ok".getBytes());

        assertDoesNotThrow(() -> activityService.uploadCustomizationImage(1L, file));
    }

    @Test
    void calculateReward_shouldSupportDifferentialAndCumulative() {
        Activity activity = new Activity();
        activity.setRewardTiers(List.of(
            new RewardTier(1, new Reward(100)),
            new RewardTier(3, new Reward(200))
        ));

        Reward diffReward = activityService.calculateReward(activity, 3);
        assertEquals(new Reward(100), diffReward);

        activity.setRewardMode(RewardMode.CUMULATIVE);
        Reward cumulativeReward = activityService.calculateReward(activity, 3);
        assertEquals(new Reward(200), cumulativeReward);
    }

    @Test
    void calculateMultiLevelReward_shouldApplyDecay() {
        Activity activity = new Activity();
        activity.setMultiLevelRewardRules(List.of(
            new MultiLevelRewardRule(2, new BigDecimal("0.5"))
        ));

        Reward reward = activityService.calculateMultiLevelReward(activity, new Reward(100), 2);
        assertEquals(new Reward(50), reward);
    }

    @Test
    void calculateMultiLevelReward_shouldReturnZeroWhenMissingRule() {
        Activity activity = new Activity();
        activity.setMultiLevelRewardRules(List.of(
            new MultiLevelRewardRule(2, new BigDecimal("0.5"))
        ));

        Reward reward = activityService.calculateMultiLevelReward(activity, new Reward(100), 3);
        assertEquals(new Reward(0), reward);
    }

    @Test
    void createReward_shouldThrowWhenCouponMissingOrUnsupported() {
        Reward missingBatch = new Reward("");
        assertThrows(InvalidActivityDataException.class, () -> activityService.createReward(missingBatch, false));

        Reward withBatch = new Reward("batch-1");
        assertThrows(UnsupportedOperationException.class, () -> activityService.createReward(withBatch, false));
    }

    @Test
    void createReward_shouldAllowSkipValidation() {
        Reward withBatch = new Reward("batch-1");
        assertDoesNotThrow(() -> activityService.createReward(withBatch, true));
    }

    @Test
    void generateApiKey_shouldSaveEncryptedAndReturnRawKey() {
        CreateApiKeyRequest request = new CreateApiKeyRequest();
        request.setActivityId(1L);
        request.setName("test-key");
        when(activityRepository.existsById(1L)).thenReturn(true);
        when(encryptionService.encrypt(anyString())).thenReturn("encrypted");

        ArgumentCaptor<ApiKeyEntity> captor = ArgumentCaptor.forClass(ApiKeyEntity.class);
        when(apiKeyRepository.save(captor.capture())).thenAnswer(invocation -> invocation.getArgument(0));

        String rawKey = activityService.generateApiKey(request);

        ApiKeyEntity saved = captor.getValue();
        assertNotNull(rawKey);
        assertFalse(rawKey.isBlank());
        assertEquals("encrypted", saved.getEncryptedKey());
        assertEquals("test-key", saved.getName());
        assertEquals(1L, saved.getActivityId());
        assertNotNull(saved.getSalt());
        assertNotNull(saved.getKeyHash());
        assertEquals(rawKey.substring(0, Math.min(12, rawKey.length())), saved.getKeyPrefix());
    }

    @Test
    void generateApiKey_shouldRejectWhenActivityMissing() {
        CreateApiKeyRequest request = new CreateApiKeyRequest();
        request.setActivityId(99L);
        request.setName("missing");
        when(activityRepository.existsById(99L)).thenReturn(false);

        assertThrows(ActivityNotFoundException.class, () -> activityService.generateApiKey(request));
    }

    @Test
    void validateApiKeyByPrefix_shouldUpdateLastUsedAt() {
        String rawKey = "test-api-key-12345";
        byte[] salt = new byte[16];
        Arrays.fill(salt, (byte) 1);
        ApiKeyEntity entity = buildApiKeyEntity(rawKey, salt);
        when(apiKeyRepository.findByKeyPrefix(entity.getKeyPrefix())).thenReturn(Optional.of(entity));

        activityService.validateApiKeyByPrefixAndMarkUsed(rawKey);

        assertNotNull(entity.getLastUsedAt());
        verify(apiKeyRepository).save(entity);
    }

    @Test
    void validateAndMarkApiKeyUsed_shouldUpdateLastUsedAt() {
        String rawKey = "test-api-key-98765";
        byte[] salt = new byte[16];
        Arrays.fill(salt, (byte) 2);
        ApiKeyEntity entity = buildApiKeyEntity(rawKey, salt);
        entity.setId(5L);
        when(apiKeyRepository.findById(5L)).thenReturn(Optional.of(entity));

        activityService.validateAndMarkApiKeyUsed(5L, rawKey);

        assertNotNull(entity.getLastUsedAt());
        verify(apiKeyRepository).save(entity);
    }

    @Test
    void revealApiKey_shouldRejectRevoked() {
        ApiKeyEntity entity = new ApiKeyEntity();
        entity.setId(7L);
        entity.setRevokedAt(java.time.OffsetDateTime.now());
        when(apiKeyRepository.findById(7L)).thenReturn(Optional.of(entity));

        assertThrows(InvalidApiKeyException.class, () -> activityService.revealApiKey(7L));
    }

    @Test
    void revealApiKey_shouldPersistRevealTime() {
        ApiKeyEntity entity = new ApiKeyEntity();
        entity.setId(8L);
        entity.setEncryptedKey("encrypted");
        when(apiKeyRepository.findById(8L)).thenReturn(Optional.of(entity));
        when(encryptionService.decrypt("encrypted")).thenReturn("raw");

        String raw = activityService.revealApiKey(8L);

        assertEquals("raw", raw);
        assertNotNull(entity.getRevealedAt());
        verify(apiKeyRepository).save(entity);
    }

    @Test
    void revokeApiKey_shouldSetRevokedAt() {
        ApiKeyEntity entity = new ApiKeyEntity();
        entity.setId(9L);
        when(apiKeyRepository.findById(9L)).thenReturn(Optional.of(entity));

        activityService.revokeApiKey(9L);

        assertNotNull(entity.getRevokedAt());
        verify(apiKeyRepository).save(entity);
    }

    @Test
    void markApiKeyUsed_shouldSetLastUsedAt() {
        ApiKeyEntity entity = new ApiKeyEntity();
        entity.setId(10L);
        when(apiKeyRepository.findById(10L)).thenReturn(Optional.of(entity));

        activityService.markApiKeyUsed(10L);

        assertNotNull(entity.getLastUsedAt());
        verify(apiKeyRepository).save(entity);
    }

    @Test
    void getLeaderboard_shouldReturnEmptyWhenNoInvites() {
        when(activityRepository.existsById(1L)).thenReturn(true);
        when(userInviteRepository.countInvitesByActivityIdGroupByInviter(1L)).thenReturn(List.of());

        List<com.mosquito.project.domain.LeaderboardEntry> entries = activityService.getLeaderboard(1L);

        assertEquals(0, entries.size());
    }

    @Test
    void getLeaderboard_shouldSortByScore() {
        when(activityRepository.existsById(1L)).thenReturn(true);
        when(userInviteRepository.countInvitesByActivityIdGroupByInviter(1L)).thenReturn(List.of(
            new Object[]{2L, 1L},
            new Object[]{1L, 5L}
        ));

        List<com.mosquito.project.domain.LeaderboardEntry> entries = activityService.getLeaderboard(1L);

        assertEquals(2, entries.size());
        assertEquals(1L, entries.get(0).getUserId());
        assertEquals(5, entries.get(0).getScore());
    }

    @Test
    void getActivityStats_shouldAggregateTotals() {
        when(activityRepository.existsById(1L)).thenReturn(true);
        DailyActivityStatsEntity first = new DailyActivityStatsEntity();
        first.setActivityId(1L);
        first.setStatDate(LocalDate.of(2025, 1, 1));
        first.setNewRegistrations(10);
        first.setShares(5);
        DailyActivityStatsEntity second = new DailyActivityStatsEntity();
        second.setActivityId(1L);
        second.setStatDate(LocalDate.of(2025, 1, 2));
        second.setNewRegistrations(null);
        second.setShares(7);
        when(dailyActivityStatsRepository.findByActivityIdOrderByStatDateAsc(1L)).thenReturn(List.of(first, second));

        ActivityStatsResponse response = activityService.getActivityStats(1L);

        assertEquals(10, response.getTotalParticipants());
        assertEquals(12, response.getTotalShares());
        assertEquals(2, response.getDailyStats().size());
    }

    @Test
    void getActivityGraph_shouldRespectRootDepthAndLimit() {
        when(activityRepository.existsById(1L)).thenReturn(true);
        UserInviteEntity a = new UserInviteEntity();
        a.setActivityId(1L);
        a.setInviterUserId(1L);
        a.setInviteeUserId(2L);
        UserInviteEntity b = new UserInviteEntity();
        b.setActivityId(1L);
        b.setInviterUserId(1L);
        b.setInviteeUserId(3L);
        UserInviteEntity c = new UserInviteEntity();
        c.setActivityId(1L);
        c.setInviterUserId(2L);
        c.setInviteeUserId(4L);
        when(userInviteRepository.findByActivityId(1L)).thenReturn(List.of(a, b, c));

        var graph = activityService.getActivityGraph(1L, 1L, 1, 1);

        assertEquals(1, graph.getEdges().size());
        assertEquals(2, graph.getNodes().size());
    }

    @Test
    void getActivityGraph_shouldReturnEdgesWhenRootNull() {
        when(activityRepository.existsById(1L)).thenReturn(true);
        UserInviteEntity a = new UserInviteEntity();
        a.setActivityId(1L);
        a.setInviterUserId(1L);
        a.setInviteeUserId(2L);
        UserInviteEntity b = new UserInviteEntity();
        b.setActivityId(1L);
        b.setInviterUserId(2L);
        b.setInviteeUserId(3L);
        when(userInviteRepository.findByActivityId(1L)).thenReturn(List.of(a, b));

        var graph = activityService.getActivityGraph(1L, null, null, 1);

        assertEquals(1, graph.getEdges().size());
        assertEquals(2, graph.getNodes().size());
    }

    private static ApiKeyEntity buildApiKeyEntity(String rawKey, byte[] salt) {
        ApiKeyEntity entity = new ApiKeyEntity();
        entity.setSalt(Base64.getEncoder().encodeToString(salt));
        entity.setKeyHash(hashApiKey(rawKey, salt));
        entity.setKeyPrefix(rawKey.substring(0, Math.min(12, rawKey.length())));
        return entity;
    }

    private static String hashApiKey(String apiKey, byte[] salt) {
        try {
            SecretKeyFactory skf = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
            PBEKeySpec spec = new PBEKeySpec(apiKey.toCharArray(), salt, 185000, 256);
            byte[] derived = skf.generateSecret(spec).getEncoded();
            return Base64.getEncoder().encodeToString(derived);
        } catch (Exception e) {
            throw new RuntimeException("hash api key failed", e);
        }
    }

    @Test
    void calculateReward_shouldReturnZeroWhenNoTiers() {
        Activity activity = new Activity();
        activity.setRewardTiers(null);

        Reward reward = activityService.calculateReward(activity, 5);

        assertEquals(new Reward(0), reward);
    }

    @Test
    void calculateReward_shouldReturnZeroWhenEmptyTiers() {
        Activity activity = new Activity();
        activity.setRewardTiers(List.of());

        Reward reward = activityService.calculateReward(activity, 5);

        assertEquals(new Reward(0), reward);
    }

    @Test
    void calculateReward_shouldReturnZeroWhenNoTierAchieved() {
        Activity activity = new Activity();
        activity.setRewardTiers(List.of(
            new RewardTier(5, new Reward(100)),
            new RewardTier(10, new Reward(200))
        ));

        Reward reward = activityService.calculateReward(activity, 3);

        assertEquals(new Reward(0), reward);
    }

    @Test
    void calculateReward_shouldReturnFirstTierInDifferentialMode() {
        Activity activity = new Activity();
        activity.setRewardTiers(List.of(
            new RewardTier(1, new Reward(100))
        ));

        Reward reward = activityService.calculateReward(activity, 1);

        assertEquals(new Reward(100), reward);
    }

    @Test
    void calculateMultiLevelReward_shouldReturnZeroWhenRulesNull() {
        Activity activity = new Activity();
        activity.setMultiLevelRewardRules(null);

        Reward reward = activityService.calculateMultiLevelReward(activity, new Reward(100), 2);

        assertEquals(new Reward(0), reward);
    }

    @Test
    void generateLeaderboardCsv_shouldHandleNullTopN() {
        when(activityRepository.existsById(1L)).thenReturn(true);
        when(userInviteRepository.countInvitesByActivityIdGroupByInviter(1L)).thenReturn(List.of(
            new Object[]{1L, 5L},
            new Object[]{2L, 3L}
        ));

        String csv = activityService.generateLeaderboardCsv(1L, null);

        assertNotNull(csv);
        assertEquals(3, csv.lines().count());
    }

    @Test
    void generateLeaderboardCsv_shouldHandleZeroTopN() {
        when(activityRepository.existsById(1L)).thenReturn(true);
        when(userInviteRepository.countInvitesByActivityIdGroupByInviter(1L)).thenReturn(
            java.util.Collections.singletonList(new Object[]{1L, 5L})
        );

        String csv = activityService.generateLeaderboardCsv(1L, 0);

        assertNotNull(csv);
        // topN < 1 uses entries.size(), so 1 header + 1 data row = 2 lines
        assertEquals(2, csv.lines().count());
    }

    @Test
    void generateLeaderboardCsv_shouldHandleTopNLargerThanSize() {
        when(activityRepository.existsById(1L)).thenReturn(true);
        when(userInviteRepository.countInvitesByActivityIdGroupByInviter(1L)).thenReturn(
            java.util.Collections.singletonList(new Object[]{1L, 5L})
        );

        String csv = activityService.generateLeaderboardCsv(1L, 100);

        assertNotNull(csv);
        assertEquals(2, csv.lines().count());
    }

    @Test
    void generateLeaderboardCsv_shouldUseDefaultOverload() {
        when(activityRepository.existsById(1L)).thenReturn(true);
        when(userInviteRepository.countInvitesByActivityIdGroupByInviter(1L)).thenReturn(
            java.util.Collections.singletonList(new Object[]{1L, 5L})
        );

        String csv = activityService.generateLeaderboardCsv(1L);

        assertNotNull(csv);
        assertEquals(2, csv.lines().count());
    }

    @Test
    void getActivityGraph_shouldHandleNullMaxDepth() {
        when(activityRepository.existsById(1L)).thenReturn(true);
        UserInviteEntity a = new UserInviteEntity();
        a.setActivityId(1L);
        a.setInviterUserId(1L);
        a.setInviteeUserId(2L);
        when(userInviteRepository.findByActivityId(1L)).thenReturn(List.of(a));

        var graph = activityService.getActivityGraph(1L, 1L, null, null);

        assertEquals(1, graph.getEdges().size());
    }

    @Test
    void getActivityGraph_shouldHandleZeroMaxDepth() {
        when(activityRepository.existsById(1L)).thenReturn(true);
        UserInviteEntity a = new UserInviteEntity();
        a.setActivityId(1L);
        a.setInviterUserId(1L);
        a.setInviteeUserId(2L);
        when(userInviteRepository.findByActivityId(1L)).thenReturn(List.of(a));

        var graph = activityService.getActivityGraph(1L, 1L, 0, null);

        // maxDepth < 1 uses default 1, so edges will be added
        assertEquals(1, graph.getEdges().size());
    }

    @Test
    void getActivityGraph_shouldHandleZeroLimit() {
        when(activityRepository.existsById(1L)).thenReturn(true);
        UserInviteEntity a = new UserInviteEntity();
        a.setActivityId(1L);
        a.setInviterUserId(1L);
        a.setInviteeUserId(2L);
        when(userInviteRepository.findByActivityId(1L)).thenReturn(List.of(a));

        var graph = activityService.getActivityGraph(1L, 1L, 1, 0);

        // limit < 1 uses default 1000, so edges will be added
        assertEquals(1, graph.getEdges().size());
    }

    @Test
    void getActivityGraph_shouldStopAtMaxDepth() {
        when(activityRepository.existsById(1L)).thenReturn(true);
        UserInviteEntity a = new UserInviteEntity();
        a.setActivityId(1L);
        a.setInviterUserId(1L);
        a.setInviteeUserId(2L);
        UserInviteEntity b = new UserInviteEntity();
        b.setActivityId(1L);
        b.setInviterUserId(2L);
        b.setInviteeUserId(3L);
        UserInviteEntity c = new UserInviteEntity();
        c.setActivityId(1L);
        c.setInviterUserId(3L);
        c.setInviteeUserId(4L);
        when(userInviteRepository.findByActivityId(1L)).thenReturn(List.of(a, b, c));

        var graph = activityService.getActivityGraph(1L, 1L, 2, 1000);

        assertEquals(2, graph.getEdges().size());
    }

    @Test
    void validateApiKeyByPrefix_shouldRejectRevokedKey() {
        String rawKey = "test-api-key-12345";
        byte[] salt = new byte[16];
        Arrays.fill(salt, (byte) 1);
        ApiKeyEntity entity = buildApiKeyEntity(rawKey, salt);
        entity.setRevokedAt(java.time.OffsetDateTime.now());
        when(apiKeyRepository.findByKeyPrefix(entity.getKeyPrefix())).thenReturn(Optional.of(entity));

        assertThrows(InvalidApiKeyException.class, () -> activityService.validateApiKeyByPrefixAndMarkUsed(rawKey));
    }

    @Test
    void validateApiKeyByPrefix_shouldRejectInvalidHash() {
        String rawKey = "test-api-key-12345";
        String wrongKey = "wrong-key-123456";
        byte[] salt = new byte[16];
        Arrays.fill(salt, (byte) 1);
        ApiKeyEntity entity = buildApiKeyEntity(rawKey, salt);
        when(apiKeyRepository.findByKeyPrefix(wrongKey.substring(0, 12))).thenReturn(Optional.of(entity));

        assertThrows(InvalidApiKeyException.class, () -> activityService.validateApiKeyByPrefixAndMarkUsed(wrongKey));
    }

    @Test
    void validateApiKeyByPrefix_shouldRejectMissingKey() {
        when(apiKeyRepository.findByKeyPrefix("wrong-key-12")).thenReturn(Optional.empty());

        assertThrows(InvalidApiKeyException.class, () -> activityService.validateApiKeyByPrefixAndMarkUsed("wrong-key-12345"));
    }

    @Test
    void validateAndMarkApiKeyUsed_shouldRejectRevokedKey() {
        String rawKey = "test-api-key-98765";
        byte[] salt = new byte[16];
        Arrays.fill(salt, (byte) 2);
        ApiKeyEntity entity = buildApiKeyEntity(rawKey, salt);
        entity.setId(5L);
        entity.setRevokedAt(java.time.OffsetDateTime.now());
        when(apiKeyRepository.findById(5L)).thenReturn(Optional.of(entity));

        assertThrows(InvalidApiKeyException.class, () -> activityService.validateAndMarkApiKeyUsed(5L, rawKey));
    }

    @Test
    void validateAndMarkApiKeyUsed_shouldRejectInvalidHash() {
        String rawKey = "test-api-key-98765";
        String wrongKey = "wrong-key-987654";
        byte[] salt = new byte[16];
        Arrays.fill(salt, (byte) 2);
        ApiKeyEntity entity = buildApiKeyEntity(rawKey, salt);
        entity.setId(5L);
        when(apiKeyRepository.findById(5L)).thenReturn(Optional.of(entity));

        assertThrows(InvalidApiKeyException.class, () -> activityService.validateAndMarkApiKeyUsed(5L, wrongKey));
    }

    @Test
    void validateAndMarkApiKeyUsed_shouldRejectMissingKey() {
        when(apiKeyRepository.findById(99L)).thenReturn(Optional.empty());

        assertThrows(com.mosquito.project.exception.ApiKeyNotFoundException.class, () -> activityService.validateAndMarkApiKeyUsed(99L, "any-key"));
    }

    @Test
    void uploadCustomizationImage_shouldRejectNullContentType() {
        MockMultipartFile file = new MockMultipartFile("file", "note.txt", null, "hello".getBytes());

        assertThrows(FileUploadException.class, () -> activityService.uploadCustomizationImage(1L, file));
    }

    @Test
    void accessActivity_shouldAllowWhenTargetUsersNull() {
        Activity activity = new Activity();
        activity.setTargetUserIds(null);
        User user = new User(3L, "user");

        assertDoesNotThrow(() -> activityService.accessActivity(activity, user));
    }

    @Test
    void accessActivity_shouldAllowWhenUserInTargetUsers() {
        Activity activity = new Activity();
        activity.setTargetUserIds(Set.of(1L, 2L, 3L));
        User user = new User(3L, "user");

        assertDoesNotThrow(() -> activityService.accessActivity(activity, user));
    }

    @Test
    void updateActivity_shouldUpdateSuccessfully() {
        com.mosquito.project.dto.UpdateActivityRequest request = new com.mosquito.project.dto.UpdateActivityRequest();
        request.setName("更新后的活动");
        request.setStartTime(java.time.ZonedDateTime.now());
        request.setEndTime(java.time.ZonedDateTime.now().plusDays(10));

        com.mosquito.project.persistence.entity.ActivityEntity entity = new com.mosquito.project.persistence.entity.ActivityEntity();
        entity.setId(1L);
        entity.setName("原活动");
        when(activityRepository.findById(1L)).thenReturn(Optional.of(entity));
        when(activityRepository.save(entity)).thenReturn(entity);

        Activity result = activityService.updateActivity(1L, request);

        assertNotNull(result);
        assertEquals(1L, result.getId());
        assertEquals("更新后的活动", result.getName());
    }

    @Test
    void updateActivity_shouldThrowWhenActivityNotFound() {
        com.mosquito.project.dto.UpdateActivityRequest request = new com.mosquito.project.dto.UpdateActivityRequest();
        request.setName("更新");
        request.setStartTime(java.time.ZonedDateTime.now());
        request.setEndTime(java.time.ZonedDateTime.now().plusDays(5));

        when(activityRepository.findById(999L)).thenReturn(Optional.empty());

        assertThrows(ActivityNotFoundException.class, () -> activityService.updateActivity(999L, request));
    }

    @Test
    void updateActivity_shouldThrowWhenEndTimeBeforeStartTime() {
        com.mosquito.project.dto.UpdateActivityRequest request = new com.mosquito.project.dto.UpdateActivityRequest();
        request.setName("无效活动");
        request.setStartTime(java.time.ZonedDateTime.now().plusDays(10));
        request.setEndTime(java.time.ZonedDateTime.now());

        com.mosquito.project.persistence.entity.ActivityEntity entity = new com.mosquito.project.persistence.entity.ActivityEntity();
        entity.setId(1L);
        when(activityRepository.findById(1L)).thenReturn(Optional.of(entity));

        assertThrows(InvalidActivityDataException.class, () -> activityService.updateActivity(1L, request));
    }

    @Test
    void getActivityById_shouldReturnActivity() {
        com.mosquito.project.persistence.entity.ActivityEntity entity = new com.mosquito.project.persistence.entity.ActivityEntity();
        entity.setId(1L);
        entity.setName("测试活动");
        entity.setStartTimeUtc(java.time.OffsetDateTime.now());
        entity.setEndTimeUtc(java.time.OffsetDateTime.now().plusDays(7));
        when(activityRepository.findById(1L)).thenReturn(Optional.of(entity));

        Activity result = activityService.getActivityById(1L);

        assertNotNull(result);
        assertEquals(1L, result.getId());
        assertEquals("测试活动", result.getName());
    }

    @Test
    void getActivityById_shouldThrowWhenNotFound() {
        when(activityRepository.findById(999L)).thenReturn(Optional.empty());

        assertThrows(ActivityNotFoundException.class, () -> activityService.getActivityById(999L));
    }

    @Test
    void createActivity_shouldThrowWhenEndTimeBeforeStartTime() {
        // Given
        com.mosquito.project.dto.CreateActivityRequest request = new com.mosquito.project.dto.CreateActivityRequest();
        request.setName("无效活动");
        request.setStartTime(java.time.ZonedDateTime.now().plusDays(10));
        request.setEndTime(java.time.ZonedDateTime.now()); // 结束时间早于开始时间

        // When & Then
        assertThrows(InvalidActivityDataException.class, () -> activityService.createActivity(request));
    }

    @Test
    void evictActivityCache_shouldLogEviction() {
        // Given
        Long activityId = 1L;

        // When - 调用缓存清除方法
        assertDoesNotThrow(() -> activityService.evictActivityCache(activityId));

        // Then - 方法应该成功执行（会记录日志）
        // 注意：这个测试主要是为了覆盖evictActivityCache方法的执行路径
    }
}

