package dev.lions.unionflow.server.service;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.contains;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

import dev.lions.unionflow.server.api.dto.cotisation.response.CotisationResponse;
import dev.lions.unionflow.server.entity.Cotisation;
import dev.lions.unionflow.server.entity.Membre;
import dev.lions.unionflow.server.entity.Organisation;
import dev.lions.unionflow.server.repository.CotisationRepository;
import dev.lions.unionflow.server.repository.MembreRepository;
import dev.lions.unionflow.server.repository.OrganisationRepository;
import dev.lions.unionflow.server.service.support.SecuriteHelper;
import io.quarkus.panache.common.Page;
import io.quarkus.panache.common.Sort;
import io.quarkus.test.InjectMock;
import io.quarkus.test.junit.QuarkusTest;
import jakarta.inject.Inject;
import jakarta.persistence.EntityManager;
import jakarta.persistence.TypedQuery;

import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

/**
 * Tests ciblant les branches admin non couvertes dans {@link CotisationService}.
 *
 * <p>Branches couvertes :
 * <ul>
 *   <li>getMesCotisations() — admin path (roles contient ADMIN ou ADMIN_ORGANISATION)</li>
 *   <li>getMesCotisations() — admin path avec orgs vides → liste vide</li>
 *   <li>getMesCotisations() — admin path avec orgs → retourne les cotisations</li>
 *   <li>getMesCotisationsEnAttente() — admin path avec orgs → retourne les cotisations en attente</li>
 *   <li>getMesCotisationsEnAttente() — admin path avec orgs vides → liste vide</li>
 *   <li>getMesCotisationsSynthese() — email null/blank → syntheseVide()</li>
 *   <li>getMesCotisationsSynthese() — admin path avec orgs vides → syntheseVide()</li>
 * </ul>
 */
@QuarkusTest
@DisplayName("CotisationService — branches admin path (mocks)")
class CotisationServiceAdminBranchesTest {

    @Inject
    CotisationService cotisationService;

    @InjectMock
    CotisationRepository cotisationRepository;

    @InjectMock
    MembreRepository membreRepository;

    @InjectMock
    OrganisationRepository organisationRepository;

    @InjectMock
    SecuriteHelper securiteHelper;

    @InjectMock
    OrganisationService organisationService;

    // =========================================================================
    // getMesCotisations() — admin path
    // =========================================================================

    @Nested
    @DisplayName("getMesCotisations() — branches admin")
    class GetMesCotisationsAdminTest {

        @Test
        @DisplayName("ADMIN avec organisations → retourne les cotisations des organisations")
        void getMesCotisations_adminAvecOrgs_retourneCotisations() {
            UUID orgId = UUID.randomUUID();
            Organisation org = new Organisation();
            org.setId(orgId);
            org.setNom("Org Admin");

            Cotisation cotisation = new Cotisation();
            cotisation.setId(UUID.randomUUID());
            cotisation.setNumeroReference("COT-ADMIN-001");
            cotisation.setMontantDu(BigDecimal.valueOf(5000));
            cotisation.setMontantPaye(BigDecimal.ZERO);
            cotisation.setStatut("EN_ATTENTE");
            cotisation.setDateEcheance(LocalDate.now().plusMonths(1));
            cotisation.setAnnee(LocalDate.now().getYear());
            cotisation.setActif(true);

            when(securiteHelper.resolveEmail()).thenReturn("admin@unionflow.com");
            when(securiteHelper.getRoles()).thenReturn(Set.of("ADMIN"));
            when(organisationService.listerOrganisationsPourUtilisateur("admin@unionflow.com"))
                    .thenReturn(List.of(org));
            when(cotisationRepository.findByOrganisationIdIn(
                    any(), any(Page.class), any(Sort.class)))
                    .thenReturn(List.of(cotisation));

            List<CotisationResponse> result = cotisationService.getMesCotisations(0, 10);

            assertThat(result).isNotNull().hasSize(1);
            assertThat(result.get(0).getId()).isEqualTo(cotisation.getId());
        }

        @Test
        @DisplayName("ADMIN avec organisations vides → retourne liste vide")
        void getMesCotisations_adminAvecOrgsVides_retourneListeVide() {
            when(securiteHelper.resolveEmail()).thenReturn("admin@unionflow.com");
            when(securiteHelper.getRoles()).thenReturn(Set.of("ADMIN"));
            when(organisationService.listerOrganisationsPourUtilisateur("admin@unionflow.com"))
                    .thenReturn(Collections.emptyList());

            List<CotisationResponse> result = cotisationService.getMesCotisations(0, 10);

            assertThat(result).isNotNull().isEmpty();
        }

        @Test
        @DisplayName("ADMIN_ORGANISATION avec organisations → branche ADMIN_ORGANISATION couverte")
        void getMesCotisations_adminOrganisationAvecOrgs_retourneCotisations() {
            UUID orgId = UUID.randomUUID();
            Organisation org = new Organisation();
            org.setId(orgId);
            org.setNom("Org Admin Org");

            when(securiteHelper.resolveEmail()).thenReturn("admin-org@unionflow.com");
            when(securiteHelper.getRoles()).thenReturn(Set.of("ADMIN_ORGANISATION"));
            when(organisationService.listerOrganisationsPourUtilisateur("admin-org@unionflow.com"))
                    .thenReturn(List.of(org));
            when(cotisationRepository.findByOrganisationIdIn(
                    any(), any(Page.class), any(Sort.class)))
                    .thenReturn(Collections.emptyList());

            List<CotisationResponse> result = cotisationService.getMesCotisations(0, 10);

            assertThat(result).isNotNull().isEmpty();
        }

        @Test
        @DisplayName("ADMIN avec orgs null → retourne liste vide (branche orgs == null)")
        void getMesCotisations_adminAvecOrgsNull_retourneListeVide() {
            when(securiteHelper.resolveEmail()).thenReturn("admin@unionflow.com");
            when(securiteHelper.getRoles()).thenReturn(Set.of("ADMIN"));
            when(organisationService.listerOrganisationsPourUtilisateur("admin@unionflow.com"))
                    .thenReturn(null);

            List<CotisationResponse> result = cotisationService.getMesCotisations(0, 10);

            assertThat(result).isNotNull().isEmpty();
        }

        @Test
        @DisplayName("email null → retourne liste vide (branche email null/blank)")
        void getMesCotisations_emailNull_retourneListeVide() {
            when(securiteHelper.resolveEmail()).thenReturn(null);

            List<CotisationResponse> result = cotisationService.getMesCotisations(0, 10);

            assertThat(result).isNotNull().isEmpty();
        }

        @Test
        @DisplayName("email blank → retourne liste vide (branche email blank)")
        void getMesCotisations_emailBlank_retourneListeVide() {
            when(securiteHelper.resolveEmail()).thenReturn("  ");

            List<CotisationResponse> result = cotisationService.getMesCotisations(0, 10);

            assertThat(result).isNotNull().isEmpty();
        }

        @Test
        @DisplayName("MEMBRE sans compte en base → retourne liste vide (branche membreConnecte == null)")
        void getMesCotisations_membrePasEnBase_retourneListeVide() {
            when(securiteHelper.resolveEmail()).thenReturn("membre-inconnu@unionflow.com");
            when(securiteHelper.getRoles()).thenReturn(Set.of("MEMBRE"));
            when(membreRepository.findByEmail("membre-inconnu@unionflow.com"))
                    .thenReturn(Optional.empty());

            List<CotisationResponse> result = cotisationService.getMesCotisations(0, 10);

            assertThat(result).isNotNull().isEmpty();
        }
    }

    // =========================================================================
    // getMesCotisationsEnAttente() — admin path
    // =========================================================================

    @Nested
    @DisplayName("getMesCotisationsEnAttente() — branches admin")
    class GetMesCotisationsEnAttenteAdminTest {

        @Test
        @DisplayName("ADMIN avec organisations → retourne les cotisations en attente")
        void getMesCotisationsEnAttente_adminAvecOrgs_retourneCotisationsEnAttente() {
            UUID orgId = UUID.randomUUID();
            Organisation org = new Organisation();
            org.setId(orgId);
            org.setNom("Org Admin EnAttente");

            Cotisation cotisation = new Cotisation();
            cotisation.setId(UUID.randomUUID());
            cotisation.setNumeroReference("COT-ATT-001");
            cotisation.setMontantDu(BigDecimal.valueOf(3000));
            cotisation.setMontantPaye(BigDecimal.ZERO);
            cotisation.setStatut("EN_ATTENTE");
            cotisation.setDateEcheance(LocalDate.now().plusMonths(1));
            cotisation.setAnnee(LocalDate.now().getYear());
            cotisation.setActif(true);

            when(securiteHelper.resolveEmail()).thenReturn("admin-enattente@unionflow.com");
            when(securiteHelper.getRoles()).thenReturn(Set.of("ADMIN"));
            when(organisationService.listerOrganisationsPourUtilisateur("admin-enattente@unionflow.com"))
                    .thenReturn(List.of(org));
            when(cotisationRepository.findEnAttenteByOrganisationIdIn(any()))
                    .thenReturn(List.of(cotisation));

            List<CotisationResponse> result = cotisationService.getMesCotisationsEnAttente();

            assertThat(result).isNotNull().hasSize(1);
            assertThat(result.get(0).getStatut()).isEqualTo("EN_ATTENTE");
        }

        @Test
        @DisplayName("ADMIN_ORGANISATION avec organisations → retourne les cotisations en attente (L740 branche ADMIN_ORGANISATION)")
        void getMesCotisationsEnAttente_adminOrganisationAvecOrgs_retourneCotisations() {
            UUID orgId = UUID.randomUUID();
            Organisation org = new Organisation();
            org.setId(orgId);
            org.setNom("Org AdminOrg EnAttente");

            Cotisation cotisation = new Cotisation();
            cotisation.setId(UUID.randomUUID());
            cotisation.setNumeroReference("COT-ATT-ADMINORG");
            cotisation.setMontantDu(BigDecimal.valueOf(5000));
            cotisation.setMontantPaye(BigDecimal.ZERO);
            cotisation.setStatut("EN_ATTENTE");
            cotisation.setDateEcheance(LocalDate.now().plusMonths(2));
            cotisation.setAnnee(LocalDate.now().getYear());
            cotisation.setActif(true);

            when(securiteHelper.resolveEmail()).thenReturn("adminorg-enattente@unionflow.com");
            when(securiteHelper.getRoles()).thenReturn(Set.of("ADMIN_ORGANISATION"));
            when(organisationService.listerOrganisationsPourUtilisateur("adminorg-enattente@unionflow.com"))
                    .thenReturn(List.of(org));
            when(cotisationRepository.findEnAttenteByOrganisationIdIn(any()))
                    .thenReturn(List.of(cotisation));

            List<CotisationResponse> result = cotisationService.getMesCotisationsEnAttente();

            assertThat(result).isNotNull().hasSize(1);
            assertThat(result.get(0).getStatut()).isEqualTo("EN_ATTENTE");
        }

        @Test
        @DisplayName("ADMIN avec organisations vides → retourne liste vide")
        void getMesCotisationsEnAttente_adminAvecOrgsVides_retourneListeVide() {
            when(securiteHelper.resolveEmail()).thenReturn("admin-vide@unionflow.com");
            when(securiteHelper.getRoles()).thenReturn(Set.of("ADMIN"));
            when(organisationService.listerOrganisationsPourUtilisateur("admin-vide@unionflow.com"))
                    .thenReturn(Collections.emptyList());

            List<CotisationResponse> result = cotisationService.getMesCotisationsEnAttente();

            assertThat(result).isNotNull().isEmpty();
        }

        @Test
        @DisplayName("email null → retourne liste vide")
        void getMesCotisationsEnAttente_emailNull_retourneListeVide() {
            when(securiteHelper.resolveEmail()).thenReturn(null);

            List<CotisationResponse> result = cotisationService.getMesCotisationsEnAttente();

            assertThat(result).isNotNull().isEmpty();
        }

        @Test
        @DisplayName("MEMBRE sans compte en base → retourne liste vide (branche membreConnecte == null)")
        void getMesCotisationsEnAttente_membrePasEnBase_retourneListeVide() {
            when(securiteHelper.resolveEmail()).thenReturn("inconnu@unionflow.com");
            when(securiteHelper.getRoles()).thenReturn(Set.of("MEMBRE"));
            when(membreRepository.findByEmail("inconnu@unionflow.com")).thenReturn(Optional.empty());

            List<CotisationResponse> result = cotisationService.getMesCotisationsEnAttente();

            assertThat(result).isNotNull().isEmpty();
        }

        @Test
        @DisplayName("roles null → skip admin path → membre path (L740 roles != null = false)")
        void getMesCotisationsEnAttente_rolesNull_cheminMembre() {
            // roles == null → L740: roles != null = false → skip admin path → membre path
            when(securiteHelper.resolveEmail()).thenReturn("roles.null@unionflow.com");
            when(securiteHelper.getRoles()).thenReturn(null);
            when(membreRepository.findByEmail("roles.null@unionflow.com")).thenReturn(Optional.empty());

            List<CotisationResponse> result = cotisationService.getMesCotisationsEnAttente();

            assertThat(result).isNotNull().isEmpty();
        }

        @Test
        @DisplayName("email blank → retourne liste vide (L736 isBlank = true)")
        void getMesCotisationsEnAttente_emailBlank_retourneListeVide() {
            when(securiteHelper.resolveEmail()).thenReturn("  "); // blank → L736 true

            List<CotisationResponse> result = cotisationService.getMesCotisationsEnAttente();

            assertThat(result).isNotNull().isEmpty();
        }
    }

    // =========================================================================
    // getMesCotisationsSynthese() — email null/blank et admin avec orgs vides
    // =========================================================================

    @Nested
    @DisplayName("getMesCotisationsSynthese() — branches email null et admin orgs vides")
    class GetMesCotisationsSyntheseEmailNullTest {

        @Test
        @DisplayName("email null → syntheseVide() retournée (branche email null)")
        void getMesCotisationsSynthese_emailNull_retourneSyntheseVide() {
            when(securiteHelper.resolveEmail()).thenReturn(null);

            Map<String, Object> synthese = cotisationService.getMesCotisationsSynthese();

            assertThat(synthese).isNotNull();
            assertThat(((Number) synthese.get("cotisationsEnAttente")).intValue()).isEqualTo(0);
            assertThat((BigDecimal) synthese.get("montantDu")).isEqualByComparingTo(BigDecimal.ZERO);
            assertThat((BigDecimal) synthese.get("totalPayeAnnee")).isEqualByComparingTo(BigDecimal.ZERO);
        }

        @Test
        @DisplayName("email blank → syntheseVide() retournée (branche email blank)")
        void getMesCotisationsSynthese_emailBlank_retourneSyntheseVide() {
            when(securiteHelper.resolveEmail()).thenReturn("   ");

            Map<String, Object> synthese = cotisationService.getMesCotisationsSynthese();

            assertThat(synthese).isNotNull();
            assertThat(((Number) synthese.get("cotisationsEnAttente")).intValue()).isEqualTo(0);
        }

        @Test
        @DisplayName("ADMIN avec orgs vides → syntheseVide() retournée")
        void getMesCotisationsSynthese_adminAvecOrgsVides_retourneSyntheseVide() {
            when(securiteHelper.resolveEmail()).thenReturn("admin-synthese@unionflow.com");
            when(securiteHelper.getRoles()).thenReturn(Set.of("ADMIN"));
            when(organisationService.listerOrganisationsPourUtilisateur("admin-synthese@unionflow.com"))
                    .thenReturn(Collections.emptyList());

            Map<String, Object> synthese = cotisationService.getMesCotisationsSynthese();

            assertThat(synthese).isNotNull();
            assertThat(((Number) synthese.get("cotisationsEnAttente")).intValue()).isEqualTo(0);
            assertThat((BigDecimal) synthese.get("montantDu")).isEqualByComparingTo(BigDecimal.ZERO);
        }

        @Test
        @DisplayName("ADMIN avec orgs null → syntheseVide() retournée")
        void getMesCotisationsSynthese_adminAvecOrgsNull_retourneSyntheseVide() {
            when(securiteHelper.resolveEmail()).thenReturn("admin-null-orgs@unionflow.com");
            when(securiteHelper.getRoles()).thenReturn(Set.of("ADMIN"));
            when(organisationService.listerOrganisationsPourUtilisateur("admin-null-orgs@unionflow.com"))
                    .thenReturn(null);

            Map<String, Object> synthese = cotisationService.getMesCotisationsSynthese();

            assertThat(synthese).isNotNull();
            assertThat(((Number) synthese.get("cotisationsEnAttente")).intValue()).isEqualTo(0);
        }

        @Test
        @DisplayName("ADMIN avec orgs valides → synthèse calculée (branche admin path complète)")
        void getMesCotisationsSynthese_adminAvecOrgsValides_synthèseCalculee() {
            UUID orgId = UUID.randomUUID();
            Organisation org = new Organisation();
            org.setId(orgId);
            org.setNom("Org Admin Synthese");

            when(securiteHelper.resolveEmail()).thenReturn("admin-synthese-full@unionflow.com");
            when(securiteHelper.getRoles()).thenReturn(Set.of("ADMIN"));
            when(organisationService.listerOrganisationsPourUtilisateur("admin-synthese-full@unionflow.com"))
                    .thenReturn(List.of(org));

            EntityManager mockEm = mock(EntityManager.class);
            when(cotisationRepository.getEntityManager()).thenReturn(mockEm);

            // Mock COUNT query → 5 cotisations en attente
            @SuppressWarnings("unchecked")
            TypedQuery<Long> countQuery = mock(TypedQuery.class);
            when(mockEm.createQuery(contains("COUNT"), eq(Long.class))).thenReturn(countQuery);
            when(countQuery.setParameter(anyString(), any())).thenReturn(countQuery);
            when(countQuery.getSingleResult()).thenReturn(5L);

            // Mock montantDu query → 25000
            @SuppressWarnings("unchecked")
            TypedQuery<BigDecimal> montantDuQuery = mock(TypedQuery.class);
            when(mockEm.createQuery(contains("montantDu"), eq(BigDecimal.class)))
                    .thenReturn(montantDuQuery);
            when(montantDuQuery.setParameter(anyString(), any())).thenReturn(montantDuQuery);
            when(montantDuQuery.getSingleResult()).thenReturn(BigDecimal.valueOf(25000));

            // Mock MIN prochaineEcheance → null
            @SuppressWarnings("unchecked")
            TypedQuery<LocalDate> echeanceQuery = mock(TypedQuery.class);
            when(mockEm.createQuery(contains("MIN"), eq(LocalDate.class))).thenReturn(echeanceQuery);
            when(echeanceQuery.setParameter(anyString(), any())).thenReturn(echeanceQuery);
            when(echeanceQuery.getSingleResult()).thenReturn(null);

            // Mock montantPaye (totalPayeAnnee) → 10000
            @SuppressWarnings("unchecked")
            TypedQuery<BigDecimal> totalPayeQuery = mock(TypedQuery.class);
            when(mockEm.createQuery(contains("montantPaye"), eq(BigDecimal.class)))
                    .thenReturn(totalPayeQuery);
            when(totalPayeQuery.setParameter(anyString(), any())).thenReturn(totalPayeQuery);
            when(totalPayeQuery.getSingleResult()).thenReturn(BigDecimal.valueOf(10000));

            Map<String, Object> synthese = cotisationService.getMesCotisationsSynthese();

            assertThat(synthese).isNotNull();
            assertThat(((Number) synthese.get("cotisationsEnAttente")).intValue()).isEqualTo(5);
            assertThat((BigDecimal) synthese.get("montantDu")).isEqualByComparingTo(BigDecimal.valueOf(25000));
            assertThat((BigDecimal) synthese.get("totalPayeAnnee")).isEqualByComparingTo(BigDecimal.valueOf(10000));
        }

        @Test
        @DisplayName("ADMIN_ORGANISATION avec orgs valides → synthèse calculée, résultats null → defaults")
        void getMesCotisationsSynthese_adminOrgAvecOrgsValides_resultatsNull() {
            // Couvre: roles.contains("ADMIN") = false, roles.contains("ADMIN_ORGANISATION") = true
            // + ternaires null: cotisationsEnAttente==null→0, montantDu==null→ZERO, totalPayeAnnee==null→ZERO
            UUID orgId = UUID.randomUUID();
            Organisation org = new Organisation();
            org.setId(orgId);
            org.setNom("Org Admin Org Synthese");

            when(securiteHelper.resolveEmail()).thenReturn("admin-org-synth@unionflow.com");
            when(securiteHelper.getRoles()).thenReturn(Set.of("ADMIN_ORGANISATION")); // ADMIN false, ADMIN_ORGANISATION true
            when(organisationService.listerOrganisationsPourUtilisateur("admin-org-synth@unionflow.com"))
                    .thenReturn(List.of(org));

            EntityManager mockEm = mock(EntityManager.class);
            when(cotisationRepository.getEntityManager()).thenReturn(mockEm);

            @SuppressWarnings("unchecked")
            TypedQuery<Long> countQuery = mock(TypedQuery.class);
            when(mockEm.createQuery(contains("COUNT"), eq(Long.class))).thenReturn(countQuery);
            when(countQuery.setParameter(anyString(), any())).thenReturn(countQuery);
            when(countQuery.getSingleResult()).thenReturn(null); // null → ternaire → 0

            @SuppressWarnings("unchecked")
            TypedQuery<BigDecimal> montantDuQuery = mock(TypedQuery.class);
            when(mockEm.createQuery(contains("montantDu"), eq(BigDecimal.class)))
                    .thenReturn(montantDuQuery);
            when(montantDuQuery.setParameter(anyString(), any())).thenReturn(montantDuQuery);
            when(montantDuQuery.getSingleResult()).thenReturn(null); // null → BigDecimal.ZERO

            @SuppressWarnings("unchecked")
            TypedQuery<LocalDate> echeanceQuery = mock(TypedQuery.class);
            when(mockEm.createQuery(contains("MIN"), eq(LocalDate.class))).thenReturn(echeanceQuery);
            when(echeanceQuery.setParameter(anyString(), any())).thenReturn(echeanceQuery);
            when(echeanceQuery.getSingleResult()).thenReturn(null);

            @SuppressWarnings("unchecked")
            TypedQuery<BigDecimal> totalPayeQuery = mock(TypedQuery.class);
            when(mockEm.createQuery(contains("montantPaye"), eq(BigDecimal.class)))
                    .thenReturn(totalPayeQuery);
            when(totalPayeQuery.setParameter(anyString(), any())).thenReturn(totalPayeQuery);
            when(totalPayeQuery.getSingleResult()).thenReturn(null); // null → BigDecimal.ZERO

            Map<String, Object> synthese = cotisationService.getMesCotisationsSynthese();

            assertThat(synthese).isNotNull();
            assertThat(((Number) synthese.get("cotisationsEnAttente")).intValue()).isEqualTo(0);
            assertThat((BigDecimal) synthese.get("montantDu")).isEqualByComparingTo(BigDecimal.ZERO);
            assertThat((BigDecimal) synthese.get("totalPayeAnnee")).isEqualByComparingTo(BigDecimal.ZERO);
        }

        @Test
        @DisplayName("getMesCotisationsEnAttente — ADMIN avec orgs null → liste vide (orgs == null branche)")
        void getMesCotisationsEnAttente_adminAvecOrgsNull_retourneListeVide() {
            when(securiteHelper.resolveEmail()).thenReturn("admin-null-ea@unionflow.com");
            when(securiteHelper.getRoles()).thenReturn(Set.of("ADMIN"));
            when(organisationService.listerOrganisationsPourUtilisateur("admin-null-ea@unionflow.com"))
                    .thenReturn(null); // null → orgs == null → true → return empty

            List<CotisationResponse> result = cotisationService.getMesCotisationsEnAttente();

            assertThat(result).isNotNull().isEmpty();
        }
    }

    // =========================================================================
    // envoyerRappelsCotisationsGroupes() — catch(Exception) dans la boucle
    // =========================================================================

    @Nested
    @DisplayName("envoyerRappelsCotisationsGroupes() — catch(Exception) dans la boucle")
    class EnvoyerRappelsCatchExceptionTest {

        @Test
        @DisplayName("findCotisationsAuRappel lève RuntimeException → catch L675-676 atteint, retourne 0")
        void envoyerRappels_findCotisationsLanceException_catchAtteint_retourne0() {
            when(cotisationRepository.findCotisationsAuRappel(anyInt(), anyInt()))
                    .thenThrow(new RuntimeException("Erreur simulée base de données"));

            int result = cotisationService.envoyerRappelsCotisationsGroupes(
                    List.of(UUID.randomUUID(), UUID.randomUUID()));

            assertThat(result).isEqualTo(0);
        }

        @Test
        @DisplayName("Un seul membre, findCotisationsAuRappel lève IllegalStateException → catch atteint, retourne 0")
        void envoyerRappels_unSeulMembre_illegalStateException_retourne0() {
            when(cotisationRepository.findCotisationsAuRappel(anyInt(), anyInt()))
                    .thenThrow(new IllegalStateException("Base de données inaccessible"));

            int result = cotisationService.envoyerRappelsCotisationsGroupes(
                    List.of(UUID.randomUUID()));

            assertThat(result).isZero();
        }
    }

    // =========================================================================
    // L708: getMesCotisations — roles == null → false branch (skip admin check)
    // L740: getMesCotisationsEnAttente — roles == null → false branch
    // L788: getMesCotisationsSynthese — roles == null → false branch
    // =========================================================================

    @Nested
    @DisplayName("roles == null → false branch (L708, L740, L788)")
    class RolesNullBranchTest {

        @Test
        @DisplayName("getMesCotisations: roles=null → false branch at L708 → branche membre connecté")
        void getMesCotisations_rolesNull_L708False_membrePath() {
            when(securiteHelper.resolveEmail()).thenReturn("roles-null@unionflow.com");
            when(securiteHelper.getRoles()).thenReturn(null); // null → L708: roles != null = false → skip admin check

            Membre membre = new Membre();
            UUID membreId = java.util.UUID.randomUUID();
            membre.setId(membreId);
            membre.setEmail("roles-null@unionflow.com");
            when(membreRepository.findByEmail("roles-null@unionflow.com"))
                    .thenReturn(java.util.Optional.of(membre));
            // getCotisationsByMembre calls findByIdOptional → must return membre
            when(membreRepository.findByIdOptional(membreId))
                    .thenReturn(java.util.Optional.of(membre));
            // getCotisationsByMembre calls findByMembreId
            when(cotisationRepository.findByMembreId(
                    org.mockito.ArgumentMatchers.eq(membreId),
                    org.mockito.ArgumentMatchers.any(Page.class),
                    org.mockito.ArgumentMatchers.any(Sort.class)))
                    .thenReturn(java.util.Collections.emptyList());

            List<CotisationResponse> result = cotisationService.getMesCotisations(0, 10);
            assertThat(result).isNotNull();
        }

        @Test
        @DisplayName("getMesCotisationsEnAttente: roles=null → false branch at L740 → branche membre connecté")
        void getMesCotisationsEnAttente_rolesNull_L740False_membrePath() {
            when(securiteHelper.resolveEmail()).thenReturn("roles-null-2@unionflow.com");
            when(securiteHelper.getRoles()).thenReturn(null); // null → L740: roles != null = false

            Membre membre = new Membre();
            membre.setId(java.util.UUID.randomUUID());
            membre.setNumeroMembre("UF-ROLEN");
            membre.setEmail("roles-null-2@unionflow.com");
            when(membreRepository.findByEmail("roles-null-2@unionflow.com"))
                    .thenReturn(java.util.Optional.of(membre));

            jakarta.persistence.EntityManager mockEm = org.mockito.Mockito.mock(jakarta.persistence.EntityManager.class);
            when(cotisationRepository.getEntityManager()).thenReturn(mockEm);
            @SuppressWarnings("unchecked")
            jakarta.persistence.TypedQuery<Cotisation> q = org.mockito.Mockito.mock(jakarta.persistence.TypedQuery.class);
            when(mockEm.createQuery(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.eq(Cotisation.class))).thenReturn(q);
            when(q.setParameter(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(q);
            when(q.getResultList()).thenReturn(java.util.Collections.emptyList());

            List<CotisationResponse> result = cotisationService.getMesCotisationsEnAttente();
            assertThat(result).isNotNull();
        }

        @Test
        @DisplayName("getMesCotisationsSynthese: roles=null → false branch at L788 → branche membre connecté")
        void getMesCotisationsSynthese_rolesNull_L788False_membrePath() {
            when(securiteHelper.resolveEmail()).thenReturn("roles-null-3@unionflow.com");
            when(securiteHelper.getRoles()).thenReturn(null); // null → L788: roles != null = false

            Membre membre = new Membre();
            membre.setId(java.util.UUID.randomUUID());
            membre.setNumeroMembre("UF-ROLENS");
            membre.setEmail("roles-null-3@unionflow.com");
            when(membreRepository.findByEmail("roles-null-3@unionflow.com"))
                    .thenReturn(java.util.Optional.of(membre));

            jakarta.persistence.EntityManager mockEm = org.mockito.Mockito.mock(jakarta.persistence.EntityManager.class);
            when(cotisationRepository.getEntityManager()).thenReturn(mockEm);
            @SuppressWarnings("unchecked")
            jakarta.persistence.TypedQuery<Long> countQ = org.mockito.Mockito.mock(jakarta.persistence.TypedQuery.class);
            when(mockEm.createQuery(org.mockito.ArgumentMatchers.contains("COUNT"), org.mockito.ArgumentMatchers.eq(Long.class))).thenReturn(countQ);
            when(countQ.setParameter(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(countQ);
            when(countQ.getSingleResult()).thenReturn(2L);
            @SuppressWarnings("unchecked")
            jakarta.persistence.TypedQuery<java.math.BigDecimal> bdQ = org.mockito.Mockito.mock(jakarta.persistence.TypedQuery.class);
            when(mockEm.createQuery(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.eq(java.math.BigDecimal.class))).thenReturn(bdQ);
            when(bdQ.setParameter(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(bdQ);
            when(bdQ.getSingleResult()).thenReturn(java.math.BigDecimal.valueOf(5000));
            @SuppressWarnings("unchecked")
            jakarta.persistence.TypedQuery<java.time.LocalDate> dateQ = org.mockito.Mockito.mock(jakarta.persistence.TypedQuery.class);
            when(mockEm.createQuery(org.mockito.ArgumentMatchers.contains("MIN"), org.mockito.ArgumentMatchers.eq(java.time.LocalDate.class))).thenReturn(dateQ);
            when(dateQ.setParameter(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(dateQ);
            when(dateQ.getSingleResult()).thenReturn(null);

            Map<String, Object> synthese = cotisationService.getMesCotisationsSynthese();
            assertThat(synthese).isNotNull();
        }
    }
}
