package dev.lions.unionflow.server.service;

import static org.assertj.core.api.Assertions.*;

import dev.lions.unionflow.server.api.dto.cotisation.request.CreateCotisationRequest;
import dev.lions.unionflow.server.api.dto.cotisation.request.UpdateCotisationRequest;
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 io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.security.TestSecurity;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.NotFoundException;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.*;

@QuarkusTest
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class CotisationServiceTest {

    @Inject
    CotisationService cotisationService;
    @Inject
    CotisationRepository cotisationRepository;
    @Inject
    MembreRepository membreRepository;
    @Inject
    OrganisationRepository organisationRepository;

    private static final String TEST_USER_EMAIL = "membre-cotisation-test@unionflow.dev";
    private Organisation org;
    private Membre membre;
    private Cotisation cotisation;

    @BeforeEach
    void setUp() {
        org = Organisation.builder()
                .nom("Org Cotisation Test")
                .typeOrganisation("ASSOCIATION")
                .statut("ACTIVE")
                .email("org-cot-svc-" + System.currentTimeMillis() + "@test.com")
                .region("Abidjan")
                .build();
        org.setDateCreation(LocalDateTime.now());
        org.setActif(true);
        organisationRepository.persist(org);

        membre = Membre.builder()
                .numeroMembre("M-" + System.currentTimeMillis())
                .nom("Test")
                .prenom("Cotisation")
                .email(TEST_USER_EMAIL)
                .dateNaissance(LocalDate.of(1990, 1, 1))
                .statutCompte("ACTIF")
                .build();
        membre.setDateCreation(LocalDateTime.now());
        membre.setActif(true);
        membreRepository.persist(membre);

        cotisation = Cotisation.builder()
                .typeCotisation("MENSUELLE")
                .libelle("Cotisation test")
                .montantDu(BigDecimal.valueOf(5000))
                .montantPaye(BigDecimal.ZERO)
                .codeDevise("XOF")
                .statut("EN_ATTENTE")
                .dateEcheance(LocalDate.now().plusMonths(1))
                .annee(LocalDate.now().getYear())
                .membre(membre)
                .organisation(org)
                .build();
        cotisation.setNumeroReference("COT-TEST-" + java.util.UUID.randomUUID().toString().substring(0, 8));
        cotisationRepository.persist(cotisation);
    }

    @AfterEach
    @Transactional
    void tearDown() {
        if (cotisation != null && cotisation.getId() != null) {
            cotisationRepository.findByIdOptional(cotisation.getId()).ifPresent(cotisationRepository::delete);
        }
        if (membre != null && membre.getId() != null) {
            membreRepository.findByIdOptional(membre.getId()).ifPresent(membreRepository::delete);
        }
        if (org != null && org.getId() != null) {
            organisationRepository.findByIdOptional(org.getId()).ifPresent(organisationRepository::delete);
        }
    }

    @Test
    @Order(1)
    @DisplayName("getCotisationById inexistant → NotFoundException")
    void getCotisationById_notFound_throws() {
        assertThatThrownBy(() -> cotisationService.getCotisationById(UUID.randomUUID()))
                .isInstanceOf(NotFoundException.class)
                .hasMessageContaining("Cotisation non trouvée");
    }

    @Test
    @Order(2)
    @DisplayName("getCotisationByReference inexistant → NotFoundException")
    void getCotisationByReference_notFound_throws() {
        assertThatThrownBy(() -> cotisationService.getCotisationByReference("REF-INEXISTANTE"))
                .isInstanceOf(NotFoundException.class)
                .hasMessageContaining("référence");
    }

    @Test
    @Order(3)
    @DisplayName("createCotisation membre inexistant → NotFoundException")
    void createCotisation_membreInexistant_throws() {
        CreateCotisationRequest req = new CreateCotisationRequest(
                UUID.randomUUID(), org.getId(), "MENSUELLE", "Lib", null,
                BigDecimal.valueOf(1000), "XOF", LocalDate.now().plusMonths(1),
                null, null, null, false, null);
        assertThatThrownBy(() -> cotisationService.createCotisation(req))
                .isInstanceOf(NotFoundException.class)
                .hasMessageContaining("Membre non trouvé");
    }

    @Test
    @Order(4)
    @DisplayName("createCotisation organisation inexistante → NotFoundException")
    void createCotisation_organisationInexistante_throws() {
        CreateCotisationRequest req = new CreateCotisationRequest(
                membre.getId(), UUID.randomUUID(), "MENSUELLE", "Lib", null,
                BigDecimal.valueOf(1000), "XOF", LocalDate.now().plusMonths(1),
                null, null, null, false, null);
        assertThatThrownBy(() -> cotisationService.createCotisation(req))
                .isInstanceOf(NotFoundException.class)
                .hasMessageContaining("Organisation non trouvée");
    }

    @Test
    @Order(5)
    @DisplayName("createCotisation date échéance trop ancienne → IllegalArgumentException")
    void createCotisation_dateEcheanceTropAncienne_throws() {
        CreateCotisationRequest req = new CreateCotisationRequest(
                membre.getId(), org.getId(), "MENSUELLE", "Lib", null,
                BigDecimal.valueOf(1000), "XOF", LocalDate.now().minusYears(2),
                null, null, null, false, null);
        assertThatThrownBy(() -> cotisationService.createCotisation(req))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("échéance");
    }

    @Test
    @Order(6)
    @DisplayName("updateCotisation id inexistant → NotFoundException")
    void updateCotisation_notFound_throws() {
        UpdateCotisationRequest req = new UpdateCotisationRequest("Lib", null, BigDecimal.valueOf(2000),
                null, null, "EN_ATTENTE", null, null, null);
        assertThatThrownBy(() -> cotisationService.updateCotisation(UUID.randomUUID(), req))
                .isInstanceOf(NotFoundException.class)
                .hasMessageContaining("Cotisation non trouvée");
    }

    @Test
    @Order(7)
    @DisplayName("enregistrerPaiement id inexistant → NotFoundException")
    void enregistrerPaiement_notFound_throws() {
        assertThatThrownBy(() -> cotisationService.enregistrerPaiement(
                UUID.randomUUID(), BigDecimal.valueOf(1000), LocalDate.now(), "ESPECES", "REF"))
                .isInstanceOf(NotFoundException.class)
                .hasMessageContaining("Cotisation non trouvée");
    }

    @Test
    @Order(8)
    @DisplayName("deleteCotisation id inexistant → NotFoundException")
    void deleteCotisation_notFound_throws() {
        assertThatThrownBy(() -> cotisationService.deleteCotisation(UUID.randomUUID()))
                .isInstanceOf(NotFoundException.class)
                .hasMessageContaining("Cotisation non trouvée");
    }

    @Test
    @Order(9)
    @DisplayName("deleteCotisation déjà PAYEE → IllegalStateException")
    @Transactional
    void deleteCotisation_dejaPayee_throws() {
        Cotisation toUpdate = cotisationRepository.findById(cotisation.getId());
        toUpdate.setStatut("PAYEE");
        toUpdate.setMontantPaye(BigDecimal.valueOf(5000));
        cotisationRepository.persist(toUpdate);

        assertThatThrownBy(() -> cotisationService.deleteCotisation(cotisation.getId()))
                .isInstanceOf(IllegalStateException.class)
                .hasMessageContaining("déjà payée");
    }

    @Test
    @Order(10)
    @DisplayName("getCotisationsByMembre membre inexistant → NotFoundException")
    void getCotisationsByMembre_membreInexistant_throws() {
        assertThatThrownBy(() -> cotisationService.getCotisationsByMembre(UUID.randomUUID(), 0, 10))
                .isInstanceOf(NotFoundException.class)
                .hasMessageContaining("Membre non trouvé");
    }

    @Test
    @Order(11)
    @DisplayName("getStatistiquesCotisations sans cotisation → taux 0")
    void getStatistiquesCotisations_sansCotisation_tauxZero() {
        cotisationRepository.delete(cotisation);
        cotisation = null;
        var stats = cotisationService.getStatistiquesCotisations();
        assertThat(stats).containsKey("totalCotisations");
        assertThat(stats).containsKey("tauxPaiement");
        assertThat((Double) stats.get("tauxPaiement")).isEqualTo(0.0);
    }

    @Test
    @Order(12)
    @DisplayName("envoyerRappelsCotisationsGroupes liste vide → IllegalArgumentException")
    void envoyerRappelsCotisationsGroupes_listeVide_throws() {
        assertThatThrownBy(() -> cotisationService.envoyerRappelsCotisationsGroupes(List.of()))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("vide");
    }

    @Test
    @Order(13)
    @DisplayName("envoyerRappelsCotisationsGroupes null → IllegalArgumentException")
    void envoyerRappelsCotisationsGroupes_null_throws() {
        assertThatThrownBy(() -> cotisationService.envoyerRappelsCotisationsGroupes(null))
                .isInstanceOf(IllegalArgumentException.class);
    }

    @Test
    @Order(14)
    @DisplayName("getCotisationById existant → retourne DTO avec propriétés membre/organisation mappées")
    void getCotisationById_existant_returnsDto() {
        var dto = cotisationService.getCotisationById(cotisation.getId());
        assertThat(dto).isNotNull();
        assertThat(dto.getId()).isEqualTo(cotisation.getId());
        assertThat(dto.getStatut()).isEqualTo("EN_ATTENTE");

        // Propriétés membre (nomCompletMembre, initialesMembre, typeMembre)
        assertThat(dto.getNomMembre()).isEqualTo("Cotisation Test");
        assertThat(dto.getNomCompletMembre()).isEqualTo("Cotisation Test");
        assertThat(dto.getInitialesMembre()).isEqualTo("CT");
        assertThat(dto.getTypeMembre()).isEqualTo("Actif");

        // Propriétés organisation (regionOrganisation, iconeOrganisation)
        assertThat(dto.getNomOrganisation()).isEqualTo("Org Cotisation Test");
        assertThat(dto.getRegionOrganisation()).isEqualTo("Abidjan");
        assertThat(dto.getIconeOrganisation()).isEqualTo("pi-users"); // ASSOCIATION → pi-users

        // Type/statut pour p:tag (typeSeverity, typeIcon, statutSeverity, statutIcon)
        assertThat(dto.getType()).isEqualTo(dto.getTypeCotisation());
        assertThat(dto.getTypeLibelle()).isEqualTo("Mensuelle");
        assertThat(dto.getTypeSeverity()).isEqualTo("info");
        assertThat(dto.getTypeIcon()).isEqualTo("pi-calendar");
        assertThat(dto.getStatutSeverity()).isEqualTo("info");
        assertThat(dto.getStatutIcon()).isEqualTo("pi-clock");
        assertThat(dto.getMontantFormatte()).isNotNull();
        assertThat(dto.getDateEcheanceFormattee()).isNotNull();
        assertThat(dto.getRetardCouleur()).isNotNull();
        assertThat(dto.getRetardTexte()).isNotNull();
    }

    @Test
    @Order(15)
    @DisplayName("getCotisationById avec organisation CLUB → iconeOrganisation pi-star")
    @Transactional
    void getCotisationById_organisationClub_iconePiStar() {
        org.setTypeOrganisation("CLUB");
        organisationRepository.persist(org);

        var dto = cotisationService.getCotisationById(cotisation.getId());
        assertThat(dto).isNotNull();
        assertThat(dto.getIconeOrganisation()).isEqualTo("pi-star");
    }

    @Test
    @Order(16)
    @DisplayName("getCotisationById avec membre EN_ATTENTE_VALIDATION → typeMembre En attente")
    @Transactional
    void getCotisationById_membreEnAttente_typeMembreEnAttente() {
        membre.setStatutCompte("EN_ATTENTE_VALIDATION");
        membreRepository.persist(membre);

        var dto = cotisationService.getCotisationById(cotisation.getId());
        assertThat(dto).isNotNull();
        assertThat(dto.getTypeMembre()).isEqualTo("En attente");
    }

    @Test
    @Order(17)
    @DisplayName("getCotisationByReference existant → retourne DTO")
    void getCotisationByReference_existant_returnsDto() {
        var dto = cotisationService.getCotisationByReference(cotisation.getNumeroReference());
        assertThat(dto).isNotNull();
        assertThat(dto.getNumeroReference()).isEqualTo(cotisation.getNumeroReference());
    }

    @Test
    @Order(18)
    @DisplayName("getCotisationsByMembre existant → liste non vide")
    void getCotisationsByMembre_existant_returnsList() {
        var list = cotisationService.getCotisationsByMembre(membre.getId(), 0, 10);
        assertThat(list).isNotEmpty();
        assertThat(list.get(0).getId()).isEqualTo(cotisation.getId());
    }

    @Test
    @Order(19)
    @DisplayName("getCotisationsByStatut → liste")
    void getCotisationsByStatut_returnsList() {
        var list = cotisationService.getCotisationsByStatut("EN_ATTENTE", 0, 10);
        assertThat(list).isNotNull();
    }

    @Test
    @Order(20)
    @DisplayName("getCotisationsEnRetard → liste")
    void getCotisationsEnRetard_returnsList() {
        var list = cotisationService.getCotisationsEnRetard(0, 10);
        assertThat(list).isNotNull();
    }

    @Test
    @Order(21)
    @DisplayName("rechercherCotisations → liste")
    void rechercherCotisations_returnsList() {
        var list = cotisationService.rechercherCotisations(
                membre.getId(), "EN_ATTENTE", "MENSUELLE", LocalDate.now().getYear(), null, 0, 10);
        assertThat(list).isNotNull();
    }

    @Test
    @Order(22)
    @DisplayName("getStatistiquesPeriode → map")
    void getStatistiquesPeriode_returnsMap() {
        var map = cotisationService.getStatistiquesPeriode(LocalDate.now().getYear(), null);
        assertThat(map).isNotNull();
    }

    @Test
    @Order(23)
    @TestSecurity(user = TEST_USER_EMAIL, roles = {"MEMBRE"})
    @DisplayName("getMesCotisationsEnAttente → retourne seulement les cotisations EN_ATTENTE du membre connecté")
    void getMesCotisationsEnAttente_returnsOnlyMemberCotisations() {
        List<CotisationResponse> results = cotisationService.getMesCotisationsEnAttente();

        assertThat(results).isNotNull();
        assertThat(results).isNotEmpty();
        assertThat(results).allMatch(c -> c.getStatut().equals("EN_ATTENTE"));
        assertThat(results.get(0).getId()).isEqualTo(cotisation.getId());
    }

    @Test
    @Order(24)
    @TestSecurity(user = TEST_USER_EMAIL, roles = {"MEMBRE"})
    @DisplayName("getMesCotisationsEnAttente → filtre par année en cours")
    @Transactional
    void getMesCotisationsEnAttente_filtersCurrentYear() {
        // Créer une cotisation pour l'année suivante
        Cotisation cotisationNextYear = Cotisation.builder()
                .typeCotisation("MENSUELLE")
                .libelle("Cotisation année prochaine")
                .montantDu(BigDecimal.valueOf(3000))
                .montantPaye(BigDecimal.ZERO)
                .codeDevise("XOF")
                .statut("EN_ATTENTE")
                .dateEcheance(LocalDate.now().plusYears(1))
                .annee(LocalDate.now().getYear() + 1)
                .membre(membre)
                .organisation(org)
                .build();
        cotisationNextYear.setNumeroReference("COT-TEST-NY-" + java.util.UUID.randomUUID().toString().substring(0, 8));
        cotisationRepository.persist(cotisationNextYear);

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

        // Ne doit retourner que la cotisation de l'année en cours
        assertThat(results).isNotNull();
        assertThat(results).allMatch(c ->
            c.getDateEcheance().getYear() == LocalDate.now().getYear()
        );

        // Cleanup
        cotisationRepository.delete(cotisationNextYear);
    }

    @Test
    @Order(25)
    @TestSecurity(user = TEST_USER_EMAIL, roles = {"MEMBRE"})
    @DisplayName("getMesCotisationsSynthese → calcule les KPI corrects")
    @Transactional
    void getMesCotisationsSynthese_calculatesCorrectKPI() {
        // Créer une cotisation PAYEE pour tester totalPayeAnnee
        Cotisation cotisationPayee = Cotisation.builder()
                .typeCotisation("MENSUELLE")
                .libelle("Cotisation payée")
                .montantDu(BigDecimal.valueOf(2000))
                .montantPaye(BigDecimal.valueOf(2000))
                .codeDevise("XOF")
                .statut("PAYEE")
                .dateEcheance(LocalDate.now())
                .datePaiement(LocalDate.now().atStartOfDay())
                .annee(LocalDate.now().getYear())
                .membre(membre)
                .organisation(org)
                .build();
        cotisationPayee.setNumeroReference("COT-TEST-PY-" + java.util.UUID.randomUUID().toString().substring(0, 8));
        cotisationRepository.persist(cotisationPayee);

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

        assertThat(synthese).isNotNull();
        assertThat(synthese).containsKey("cotisationsEnAttente");
        assertThat(synthese).containsKey("montantDu");
        assertThat(synthese).containsKey("prochaineEcheance");
        assertThat(synthese).containsKey("totalPayeAnnee");
        assertThat(synthese).containsKey("anneeEnCours");

        // Vérifier les valeurs (le service retourne Integer pour cotisationsEnAttente)
        assertThat(((Number) synthese.get("cotisationsEnAttente")).intValue()).isGreaterThanOrEqualTo(1);
        assertThat((BigDecimal) synthese.get("montantDu")).isGreaterThanOrEqualTo(BigDecimal.valueOf(5000));
        assertThat((LocalDate) synthese.get("prochaineEcheance")).isNotNull();
        assertThat((BigDecimal) synthese.get("totalPayeAnnee")).isGreaterThanOrEqualTo(BigDecimal.valueOf(2000));
        assertThat((Integer) synthese.get("anneeEnCours")).isEqualTo(LocalDate.now().getYear());

        // Cleanup
        cotisationRepository.delete(cotisationPayee);
    }

    @Test
    @Order(26)
    @TestSecurity(user = "membre-inexistant@test.com", roles = {"MEMBRE"})
    @DisplayName("getMesCotisationsEnAttente → membre non trouvé → liste vide")
    void getMesCotisationsEnAttente_membreNonTrouve_returnsEmpty() {
        var result = cotisationService.getMesCotisationsEnAttente();
        assertThat(result).isNotNull().isEmpty();
    }

    @Test
    @Order(27)
    @TestSecurity(user = "membre-inexistant@test.com", roles = {"MEMBRE"})
    @DisplayName("getMesCotisationsSynthese → membre non trouvé → NotFoundException")
    void getMesCotisationsSynthese_membreNonTrouve_throws() {
        assertThatThrownBy(() -> cotisationService.getMesCotisationsSynthese())
                .isInstanceOf(NotFoundException.class)
                .hasMessageContaining("Membre non trouvé");
    }

    @Test
    @Order(28)
    @TestSecurity(user = TEST_USER_EMAIL, roles = {"MEMBRE"})
    @DisplayName("getMesCotisationsSynthese → sans cotisation PAYEE → totalPayeAnnee = 0")
    @Transactional
    void getMesCotisationsSynthese_sansCotisationPayee_totalZero() {
        // Supprimer toutes les cotisations payées
        cotisationRepository.getEntityManager()
                .createQuery("DELETE FROM Cotisation c WHERE c.statut = 'PAYEE' AND c.membre.id = :membreId")
                .setParameter("membreId", membre.getId())
                .executeUpdate();

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

        assertThat(synthese).containsKey("totalPayeAnnee");
        assertThat((BigDecimal) synthese.get("totalPayeAnnee")).isEqualTo(BigDecimal.ZERO);
    }

    // =========================================================================
    // Branches manquantes — enregistrerPaiement() montantPaye branches
    // =========================================================================

    @Test
    @Order(29)
    @DisplayName("enregistrerPaiement avec montantPaye null → statut inchangé (branche montantPaye == null → false)")
    @Transactional
    void enregistrerPaiement_montantPayeNull_statutInchange() {
        var response = cotisationService.enregistrerPaiement(
                cotisation.getId(), null, null, null, null);

        // montantPaye null → not set → cotisation reste EN_ATTENTE
        assertThat(response).isNotNull();
        assertThat(response.getStatut()).isEqualTo("EN_ATTENTE");
    }

    @Test
    @Order(30)
    @DisplayName("enregistrerPaiement avec montantPaye == montantDu → statut PAYEE (branche >= 0)")
    @Transactional
    void enregistrerPaiement_montantPayeEgalMontantDu_statutPayee() {
        var response = cotisationService.enregistrerPaiement(
                cotisation.getId(),
                BigDecimal.valueOf(5000),    // == montantDu
                LocalDate.now(), "ESPECES", "REF-FULL");

        assertThat(response).isNotNull();
        assertThat(response.getStatut()).isEqualTo("PAYEE");
    }

    @Test
    @Order(31)
    @DisplayName("enregistrerPaiement avec paiement partiel → statut PARTIELLEMENT_PAYEE (branche > 0)")
    @Transactional
    void enregistrerPaiement_paiementPartiel_statutPartiellementPayee() {
        var response = cotisationService.enregistrerPaiement(
                cotisation.getId(),
                BigDecimal.valueOf(2000),    // < montantDu (5000)
                LocalDate.now(), "MOBILE_MONEY", "REF-PARTIAL");

        assertThat(response).isNotNull();
        assertThat(response.getStatut()).isEqualTo("PARTIELLEMENT_PAYEE");
    }

    @Test
    @Order(32)
    @DisplayName("enregistrerPaiement avec datePaiement null → datePaiement non modifiée")
    @Transactional
    void enregistrerPaiement_datePaiementNull_dateNonModifiee() {
        var response = cotisationService.enregistrerPaiement(
                cotisation.getId(),
                BigDecimal.valueOf(1000),
                null,   // datePaiement null → branche false → pas de setDatePaiement
                "ESPECES", "REF-NO-DATE");

        assertThat(response).isNotNull();
        assertThat(response.getDatePaiement()).isNull();
    }

    // =========================================================================
    // Branches manquantes — validateCotisationRules() montantPaye > montantDu
    // =========================================================================

    @Test
    @Order(33)
    @DisplayName("createCotisation avec montantPaye > montantDu → IllegalArgumentException")
    void createCotisation_montantPayeSupMontantDu_throws() {
        // createCotisation initialise montantPaye = BigDecimal.ZERO,
        // donc on ne peut pas tester montantPaye > montantDu via createCotisation.
        // On utilise updateCotisation pour mettre un statut PAYEE avec montantPaye < montantDu
        // afin de couvrir la dernière branche de validateCotisationRules.
        // La branche montantPaye > montantDu est testable via updateCotisation.
        // D'abord, placer la cotisation dans un état où montantDu = 1000 et on set montantPaye = 2000
        // via enregistrerPaiement puis updateCotisation sans reset.

        // Alternative directe : injecter via le repository puis appeler updateCotisation
        // avec un montantDu inférieur au montantPaye actuel.

        // Étape 1 : enregistrer un paiement partiel
        cotisationService.enregistrerPaiement(
                cotisation.getId(), BigDecimal.valueOf(3000), null, null, null);

        // Étape 2 : tenter de réduire montantDu en dessous de montantPaye → montantPaye > montantDu
        var updateRequest = new UpdateCotisationRequest(
                null, null, BigDecimal.valueOf(1000), // nouveau montantDu = 1000 < montantPaye actuel 3000
                null, null, null, null, null, null);

        assertThatThrownBy(() -> cotisationService.updateCotisation(cotisation.getId(), updateRequest))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("montant payé ne peut pas dépasser");
    }

    @Test
    @Order(34)
    @DisplayName("validateCotisationRules — cotisation PAYEE avec montantPaye < montantDu → IllegalArgumentException")
    @Transactional
    void validateCotisationRules_payeeAvecMontantPayeInsuffisant_throws() {
        // Couvre la dernière branche de validateCotisationRules :
        // "PAYEE".equals(statut) && montantPaye < montantDu → exception
        var updateRequest = new UpdateCotisationRequest(
                null, null, null, null, null,
                "PAYEE",   // statut = PAYEE
                null, null, null);

        // montantPaye = 0 (valeur initiale), montantDu = 5000
        // → PAYEE && montantPaye(0) < montantDu(5000) → exception
        assertThatThrownBy(() -> cotisationService.updateCotisation(cotisation.getId(), updateRequest))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("payée doit avoir un montant payé égal");
    }

    @Test
    @Order(37)
    @DisplayName("getCotisationById avec cotisation en retard → retardCouleur text-red-500 et retardTexte avec jours")
    @Transactional
    void getCotisationById_cotisationEnRetard_retardCouleurRouge() {
        // Créer une cotisation en retard (dateEcheance dans le passé, statut non-PAYEE)
        Cotisation cotRetard = Cotisation.builder()
                .typeCotisation("MENSUELLE")
                .libelle("Cotisation en retard")
                .montantDu(BigDecimal.valueOf(3000))
                .montantPaye(BigDecimal.ZERO)
                .codeDevise("XOF")
                .statut("EN_ATTENTE")
                .dateEcheance(LocalDate.now().minusMonths(2))  // dans le passé → en retard
                .annee(LocalDate.now().getYear() - 1)
                .membre(membre)
                .organisation(org)
                .build();
        cotRetard.setNumeroReference("COT-RETARD-" + UUID.randomUUID().toString().substring(0, 8));
        cotisationRepository.persist(cotRetard);

        try {
            var dto = cotisationService.getCotisationById(cotRetard.getId());
            assertThat(dto).isNotNull();
            assertThat(dto.getEnRetard()).isTrue();
            // Branche isEnRetard() = true → retardCouleur = "text-red-500"
            assertThat(dto.getRetardCouleur()).isEqualTo("text-red-500");
            assertThat(dto.getRetardTexte()).contains("retard");
            // Branche isEnRetard() = true pour joursRetard → calculé
            assertThat(dto.getJoursRetard()).isGreaterThan(0L);
        } finally {
            cotisationRepository.delete(cotRetard);
        }
    }

    @Test
    @Order(38)
    @DisplayName("getCotisationById avec cotisation PAYEE non-en-retard → retardTexte 'Payée'")
    @Transactional
    void getCotisationById_cotisationPayeeNonEnRetard_retardTextePayee() {
        // Créer une cotisation PAYEE non en retard
        Cotisation cotPayee = Cotisation.builder()
                .typeCotisation("MENSUELLE")
                .libelle("Cotisation payée")
                .montantDu(BigDecimal.valueOf(4000))
                .montantPaye(BigDecimal.valueOf(4000))
                .codeDevise("XOF")
                .statut("PAYEE")
                .dateEcheance(LocalDate.now().plusMonths(1))
                .annee(LocalDate.now().getYear())
                .membre(membre)
                .organisation(org)
                .build();
        cotPayee.setNumeroReference("COT-PAYEE-" + UUID.randomUUID().toString().substring(0, 8));
        cotisationRepository.persist(cotPayee);

        try {
            var dto = cotisationService.getCotisationById(cotPayee.getId());
            assertThat(dto).isNotNull();
            assertThat(dto.getEnRetard()).isFalse();
            // Branche statut == "PAYEE" dans else → retardTexte = "Payée"
            assertThat(dto.getRetardTexte()).isEqualTo("Payée");
            assertThat(dto.getRetardCouleur()).isEqualTo("text-green-600");
            // Branche isEnRetard() = false → joursRetard = 0
            assertThat(dto.getJoursRetard()).isEqualTo(0L);
        } finally {
            cotisationRepository.delete(cotPayee);
        }
    }

    @Test
    @Order(385)
    @Transactional
    @DisplayName("updateCotisation avec tous les champs non-null → couvre toutes les branches if (L198-L215)")
    void updateCotisation_tousChamps_nonNull_couvreToutes() {
        // Crée une cotisation persistée valide
        Cotisation cot = Cotisation.builder()
                .typeCotisation("MENSUELLE")
                .libelle("Libellé original")
                .description("Description originale")
                .montantDu(BigDecimal.valueOf(5000))
                .montantPaye(BigDecimal.ZERO)
                .codeDevise("XOF")
                .statut("EN_ATTENTE")
                .dateEcheance(LocalDate.now().plusMonths(1))
                .annee(LocalDate.now().getYear())
                .mois(LocalDate.now().getMonthValue())
                .observations("Obs originale")
                .recurrente(false)
                .membre(membre)
                .organisation(org)
                .build();
        cot.setNumeroReference("COT-FULL-" + java.util.UUID.randomUUID().toString().substring(0, 8));
        cotisationRepository.persist(cot);

        try {
            // Request avec TOUS les champs non-null → couvre L198, L200, L202, L204, L206, L208, L210, L212, L214
            var updateRequest = new UpdateCotisationRequest(
                    "Libellé mis à jour",       // libelle non-null → L198 true
                    "Description mise à jour",   // description non-null → L200 true
                    BigDecimal.valueOf(5000),    // montantDu non-null → L202 true (même valeur pour passer validation)
                    LocalDate.now().plusMonths(2), // dateEcheance non-null → L204 true
                    "Observations mises à jour", // observations non-null → L206 true
                    "EN_ATTENTE",               // statut non-null → L208 true (même statut)
                    LocalDate.now().getYear(),  // annee non-null → L210 true
                    LocalDate.now().getMonthValue(), // mois non-null → L212 true
                    true                        // recurrente non-null → L214 true
            );

            var response = cotisationService.updateCotisation(cot.getId(), updateRequest);
            assertThat(response).isNotNull();
            assertThat(response.getLibelle()).isEqualTo("Libellé mis à jour");
        } finally {
            cotisationRepository.delete(cot);
        }
    }

    @Test
    @Order(39)
    @TestSecurity(user = TEST_USER_EMAIL, roles = {"MEMBRE"})
    @DisplayName("getMesCotisations comme MEMBRE → retourne les cotisations du membre connecté")
    void getMesCotisations_commeMembreConnecte_retourneSesCotsisations() {
        // Couvre la branche MEMBRE : membreConnecte != null → getCotisationsByMembre(...)
        List<CotisationResponse> result = cotisationService.getMesCotisations(0, 10);
        assertThat(result).isNotNull().isNotEmpty();
        assertThat(result.stream().anyMatch(c -> c.getId().equals(cotisation.getId()))).isTrue();
    }

    @Test
    @Order(400)
    @Transactional
    @DisplayName("updateCotisation avec montantDu=0 → BeanValidation (@DecimalMin) → ConstraintViolationException")
    void updateCotisation_montantDuZero_beanValidationBlocks() {
        // L638 in validateCotisationRules is unreachable via public API because
        // @DecimalMin(value="0.0", inclusive=false) on UpdateCotisationRequest.montantDu
        // rejects the value at bean validation level before the service method body executes.
        var updateRequest = new UpdateCotisationRequest(
                null, null,
                BigDecimal.ZERO,
                null, null, null, null, null, null);

        assertThatThrownBy(() -> cotisationService.updateCotisation(cotisation.getId(), updateRequest))
                .isInstanceOf(jakarta.validation.ConstraintViolationException.class);
    }

    @Test
    @Order(401)
    @Transactional
    @DisplayName("updateCotisation statut=PAYEE avec montantPaye==montantDu → pas d'exception")
    void updateCotisation_payeeAvecMontantPayeEgalMontantDu_L648False_pasException() {
        // Étape 1 : payer entièrement
        cotisationService.enregistrerPaiement(
                cotisation.getId(), BigDecimal.valueOf(5000), LocalDate.now(), "ESPECES", "REF-PAYEE-OK");
        // cotisation.montantPaye = 5000 = montantDu → statut = PAYEE

        // Étape 2 : updateCotisation avec statut=PAYEE explicite → validateCotisationRules
        // montantPaye (5000) >= montantDu (5000) → statut valide → pas d'exception
        var updateRequest = new UpdateCotisationRequest(
                null, null, null, null, null,
                "PAYEE",  // statut = PAYEE
                null, null, null);

        var response = cotisationService.updateCotisation(cotisation.getId(), updateRequest);
        assertThat(response).isNotNull();
        assertThat(response.getStatut()).isEqualTo("PAYEE");
    }

    @Test
    @Order(402)
    @Transactional
    @DisplayName("enregistrerPaiement avec cotisation.montantPaye=null → statut reste EN_ATTENTE")
    void enregistrerPaiement_cotisationMontantPayeNull_L249L252NullBranch() {
        // Créer directement une cotisation avec montantPaye=null (bypass createCotisation)
        Cotisation cotNullPaye = Cotisation.builder()
                .typeCotisation("MENSUELLE")
                .libelle("Cotisation montantPaye null")
                .montantDu(BigDecimal.valueOf(3000))
                .montantPaye(null)  // null → conditions de mise à jour de statut ignorées
                .codeDevise("XOF")
                .statut("EN_ATTENTE")
                .dateEcheance(LocalDate.now().plusMonths(1))
                .annee(LocalDate.now().getYear())
                .membre(membre)
                .organisation(org)
                .build();
        cotNullPaye.setNumeroReference("COT-NULL-" + UUID.randomUUID().toString().substring(0, 8));
        cotisationRepository.persist(cotNullPaye);

        try {
            // enregistrerPaiement avec montantPaye=null → aucun changement de statut
            var response = cotisationService.enregistrerPaiement(
                    cotNullPaye.getId(), null, null, null, null);
            assertThat(response).isNotNull();
            assertThat(response.getStatut()).isEqualTo("EN_ATTENTE");
        } finally {
            cotisationRepository.delete(cotNullPaye);
        }
    }

    @Test
    @Order(403)
    @Transactional
    @DisplayName("getCotisationById avec membre.statutCompte inconnu → getTypeMembreLibelle retourne la valeur brute")
    void getCotisationById_statutCompteInconnu_L529SwitchDefault() {
        // Membre avec statutCompte non reconnu → getTypeMembreLibelle retourne la valeur brute
        Membre membreStatutInconnu = Membre.builder()
                .numeroMembre("M-STAT-" + System.currentTimeMillis())
                .nom("StatutInconnu")
                .prenom("Test")
                .email("statut-inconnu-" + System.currentTimeMillis() + "@test.com")
                .dateNaissance(LocalDate.of(1990, 1, 1))
                .statutCompte("STATUT_INCONNU")
                .build();
        membreStatutInconnu.setDateCreation(java.time.LocalDateTime.now());
        membreStatutInconnu.setActif(true);
        membreRepository.persist(membreStatutInconnu);

        Cotisation cotStatut = Cotisation.builder()
                .typeCotisation("MENSUELLE")
                .libelle("Cotisation statut inconnu")
                .montantDu(BigDecimal.valueOf(1000))
                .montantPaye(BigDecimal.ZERO)
                .codeDevise("XOF")
                .statut("EN_ATTENTE")
                .dateEcheance(LocalDate.now().plusMonths(1))
                .annee(LocalDate.now().getYear())
                .membre(membreStatutInconnu)
                .organisation(org)
                .build();
        cotStatut.setNumeroReference("COT-STATI-" + UUID.randomUUID().toString().substring(0, 8));
        cotisationRepository.persist(cotStatut);

        try {
            var response = cotisationService.getCotisationById(cotStatut.getId());
            assertThat(response).isNotNull();
            assertThat(response.getTypeMembre()).isEqualTo("STATUT_INCONNU");
        } finally {
            cotisationRepository.delete(cotStatut);
            membreRepository.delete(membreStatutInconnu);
        }
    }
}
