package dev.lions.unionflow.server.service;

import dev.lions.unionflow.server.api.dto.souscription.FormuleAbonnementResponse;
import dev.lions.unionflow.server.api.dto.souscription.SouscriptionDemandeRequest;
import dev.lions.unionflow.server.api.dto.souscription.SouscriptionStatutResponse;
import dev.lions.unionflow.server.api.enums.abonnement.PlageMembres;
import dev.lions.unionflow.server.api.enums.abonnement.StatutSouscription;
import dev.lions.unionflow.server.api.enums.abonnement.StatutValidationSouscription;
import dev.lions.unionflow.server.api.enums.abonnement.TypeFormule;
import dev.lions.unionflow.server.api.enums.abonnement.TypeOrganisationFacturation;
import dev.lions.unionflow.server.api.enums.abonnement.TypePeriodeAbonnement;
import dev.lions.unionflow.server.api.enums.membre.StatutMembre;
import dev.lions.unionflow.server.entity.FormuleAbonnement;
import dev.lions.unionflow.server.entity.Membre;
import dev.lions.unionflow.server.entity.MembreOrganisation;
import dev.lions.unionflow.server.entity.Organisation;
import dev.lions.unionflow.server.entity.SouscriptionOrganisation;
import dev.lions.unionflow.server.repository.FormuleAbonnementRepository;
import dev.lions.unionflow.server.repository.MembreOrganisationRepository;
import dev.lions.unionflow.server.repository.MembreRepository;
import dev.lions.unionflow.server.repository.OrganisationRepository;
import dev.lions.unionflow.server.repository.SouscriptionOrganisationRepository;
import dev.lions.unionflow.server.service.support.SecuriteHelper;
import io.quarkus.hibernate.orm.panache.PanacheQuery;
import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.NotFoundException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

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

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;

/**
 * Pure Mockito unit tests for SouscriptionService.
 * All Panache repository methods are mocked — no container required.
 */
@ExtendWith(MockitoExtension.class)
@DisplayName("Tests SouscriptionService")
class SouscriptionServiceTest {

    @Mock SouscriptionOrganisationRepository souscriptionRepo;
    @Mock FormuleAbonnementRepository formuleRepo;
    @Mock WaveCheckoutService waveService;
    @Mock SecuriteHelper securiteHelper;
    @Mock MembreRepository membreRepository;
    @Mock OrganisationRepository organisationRepo;
    @Mock MembreService membreService;
    @Mock MembreOrganisationRepository membreOrganisationRepository;
    @Mock NotificationService notificationService;
    @Mock MembreKeycloakSyncService keycloakSyncService;
    @Mock EmailTemplateService emailTemplateService;

    @InjectMocks
    SouscriptionService service;

    // Common test data
    private static final UUID ORG_ID = UUID.randomUUID();
    private static final UUID SOUSCRIPTION_ID = UUID.randomUUID();
    private static final UUID SUPER_ADMIN_ID = UUID.randomUUID();
    private static final UUID MEMBRE_ID = UUID.randomUUID();

    private Organisation org;
    private FormuleAbonnement formule;
    private SouscriptionOrganisation souscription;

    @BeforeEach
    void setUp() {
        org = new Organisation();
        org.setId(ORG_ID);
        org.setNom("Association Test");
        org.setTypeOrganisation("ASSOCIATION");

        formule = FormuleAbonnement.builder()
                .code(TypeFormule.BASIC)
                .plage(PlageMembres.PETITE)
                .libelle("Basic Petite")
                .prixMensuel(new BigDecimal("3000"))
                .prixAnnuel(new BigDecimal("32400"))
                .maxMembres(100)
                .maxStockageMo(1024)
                .apiAccess(false)
                .federationAccess(false)
                .supportPrioritaire(false)
                .build();

        souscription = SouscriptionOrganisation.builder()
                .organisation(org)
                .formule(formule)
                .typePeriode(TypePeriodeAbonnement.MENSUEL)
                .plage(PlageMembres.PETITE)
                .typeOrganisationSouscription(TypeOrganisationFacturation.ASSOCIATION)
                .coefficientApplique(BigDecimal.ONE)
                .montantTotal(new BigDecimal("3000"))
                .statutValidation(StatutValidationSouscription.EN_ATTENTE_PAIEMENT)
                .statut(StatutSouscription.EN_ATTENTE)
                .dateDebut(LocalDate.now())
                .dateFin(LocalDate.now().plusMonths(1))
                .quotaMax(100)
                .quotaUtilise(0)
                .build();
        souscription.setId(SOUSCRIPTION_ID);
    }

    /** Helper to create a mock PanacheQuery that returns a given Optional */
    @SuppressWarnings("unchecked")
    private <T> PanacheQuery<T> panacheQueryWithFirstResult(Optional<T> result) {
        PanacheQuery<T> query = mock(PanacheQuery.class);
        when(query.firstResultOptional()).thenReturn(result);
        return query;
    }

    /** Helper to create a mock PanacheQuery that returns a given list */
    @SuppressWarnings("unchecked")
    private <T> PanacheQuery<T> panacheQueryWithList(List<T> list) {
        PanacheQuery<T> query = mock(PanacheQuery.class);
        when(query.list()).thenReturn(list);
        when(query.page(anyInt(), anyInt())).thenReturn(query);
        return query;
    }

    // ─── getFormules ────────────────────────────────────────────────────────────

    @Nested
    @DisplayName("getFormules")
    class GetFormuleTests {

        @Test
        @DisplayName("returns mapped list from repository")
        void returnsAllFormules() {
            when(formuleRepo.findAllActifOrderByOrdre()).thenReturn(List.of(formule));

            List<FormuleAbonnementResponse> result = service.getFormules();

            assertThat(result).hasSize(1);
            assertThat(result.get(0).getCode()).isEqualTo("BASIC");
        }

        @Test
        @DisplayName("returns empty list when no formules")
        void returnsEmptyList() {
            when(formuleRepo.findAllActifOrderByOrdre()).thenReturn(Collections.emptyList());

            List<FormuleAbonnementResponse> result = service.getFormules();
            assertThat(result).isEmpty();
        }

        @Test
        @DisplayName("toFormuleResponse — null ordreAffichage defaults to 0")
        void nullOrdreAffichage_defaultsToZero() {
            formule.setOrdreAffichage(null);
            when(formuleRepo.findAllActifOrderByOrdre()).thenReturn(List.of(formule));

            List<FormuleAbonnementResponse> result = service.getFormules();
            assertThat(result.get(0).getOrdreAffichage()).isEqualTo(0);
        }

        @Test
        @DisplayName("toFormuleResponse — null maxAdmins defaults to -1")
        void nullMaxAdmins_defaultsToMinusOne() {
            formule.setMaxAdmins(null);
            when(formuleRepo.findAllActifOrderByOrdre()).thenReturn(List.of(formule));

            List<FormuleAbonnementResponse> result = service.getFormules();
            assertThat(result.get(0).getMaxAdmins()).isEqualTo(-1);
        }
    }

    // ─── creerDemande ──────────────────────────────────────────────────────────

    @Nested
    @DisplayName("creerDemande")
    class CreerDemandeTests {

        private SouscriptionDemandeRequest buildRequest(String orgId, String formule,
                                                          String plage, String periode, String typeOrg) {
            SouscriptionDemandeRequest r = new SouscriptionDemandeRequest();
            r.setOrganisationId(orgId);
            r.setTypeFormule(formule);
            r.setPlageMembres(plage);
            r.setTypePeriode(periode);
            r.setTypeOrganisation(typeOrg);
            return r;
        }

        @Test
        @DisplayName("invalid UUID for organisationId — throws BadRequestException")
        void invalidUuid_throws() {
            SouscriptionDemandeRequest req = buildRequest("not-a-uuid",
                    "BASIC", "PETITE", "MENSUEL", "ASSOCIATION");

            assertThatThrownBy(() -> service.creerDemande(req))
                    .isInstanceOf(BadRequestException.class)
                    .hasMessageContaining("UUID invalide");
        }

        @Test
        @DisplayName("organisation not found — throws NotFoundException")
        void orgNotFound_throws() {
            SouscriptionDemandeRequest req = buildRequest(ORG_ID.toString(),
                    "BASIC", "PETITE", "MENSUEL", "ASSOCIATION");

            when(organisationRepo.findByIdOptional(ORG_ID)).thenReturn(Optional.empty());

            assertThatThrownBy(() -> service.creerDemande(req))
                    .isInstanceOf(NotFoundException.class)
                    .hasMessageContaining("Organisation introuvable");
        }

        @Test
        @DisplayName("existing non-rejected souscription — throws BadRequestException")
        void existingSouscription_throws() {
            SouscriptionDemandeRequest req = buildRequest(ORG_ID.toString(),
                    "BASIC", "PETITE", "MENSUEL", "ASSOCIATION");

            when(organisationRepo.findByIdOptional(ORG_ID)).thenReturn(Optional.of(org));
            PanacheQuery<SouscriptionOrganisation> q = panacheQueryWithFirstResult(
                    Optional.of(souscription));
            when(souscriptionRepo.find(anyString(), any(UUID.class),
                    eq(StatutValidationSouscription.REJETEE))).thenReturn(q);

            assertThatThrownBy(() -> service.creerDemande(req))
                    .isInstanceOf(BadRequestException.class)
                    .hasMessageContaining("souscription en cours");
        }

        @Test
        @DisplayName("invalid typeFormule value — throws BadRequestException")
        void invalidTypeFormule_throws() {
            SouscriptionDemandeRequest req = buildRequest(ORG_ID.toString(),
                    "INVALID_FORMULE", "PETITE", "MENSUEL", "ASSOCIATION");

            when(organisationRepo.findByIdOptional(ORG_ID)).thenReturn(Optional.of(org));
            PanacheQuery<SouscriptionOrganisation> q = panacheQueryWithFirstResult(Optional.empty());
            when(souscriptionRepo.find(anyString(), any(UUID.class),
                    eq(StatutValidationSouscription.REJETEE))).thenReturn(q);

            assertThatThrownBy(() -> service.creerDemande(req))
                    .isInstanceOf(BadRequestException.class)
                    .hasMessageContaining("Valeur invalide");
        }

        @Test
        @DisplayName("invalid plageMembres value — throws BadRequestException")
        void invalidPlageMembres_throws() {
            SouscriptionDemandeRequest req = buildRequest(ORG_ID.toString(),
                    "BASIC", "INVALID_PLAGE", "MENSUEL", "ASSOCIATION");

            when(organisationRepo.findByIdOptional(ORG_ID)).thenReturn(Optional.of(org));
            PanacheQuery<SouscriptionOrganisation> q = panacheQueryWithFirstResult(Optional.empty());
            when(souscriptionRepo.find(anyString(), any(UUID.class),
                    eq(StatutValidationSouscription.REJETEE))).thenReturn(q);

            assertThatThrownBy(() -> service.creerDemande(req))
                    .isInstanceOf(BadRequestException.class);
        }

        @Test
        @DisplayName("invalid typePeriode value — throws BadRequestException")
        void invalidTypePeriode_throws() {
            SouscriptionDemandeRequest req = buildRequest(ORG_ID.toString(),
                    "BASIC", "PETITE", "INVALID_PERIODE", "ASSOCIATION");

            when(organisationRepo.findByIdOptional(ORG_ID)).thenReturn(Optional.of(org));
            PanacheQuery<SouscriptionOrganisation> q = panacheQueryWithFirstResult(Optional.empty());
            when(souscriptionRepo.find(anyString(), any(UUID.class),
                    eq(StatutValidationSouscription.REJETEE))).thenReturn(q);

            assertThatThrownBy(() -> service.creerDemande(req))
                    .isInstanceOf(BadRequestException.class);
        }

        @Test
        @DisplayName("formule not found — throws NotFoundException")
        void formuleNotFound_throws() {
            SouscriptionDemandeRequest req = buildRequest(ORG_ID.toString(),
                    "BASIC", "PETITE", "MENSUEL", "ASSOCIATION");

            when(organisationRepo.findByIdOptional(ORG_ID)).thenReturn(Optional.of(org));
            PanacheQuery<SouscriptionOrganisation> q = panacheQueryWithFirstResult(Optional.empty());
            when(souscriptionRepo.find(anyString(), any(UUID.class),
                    eq(StatutValidationSouscription.REJETEE))).thenReturn(q);
            when(formuleRepo.findByCodeAndPlage(TypeFormule.BASIC, PlageMembres.PETITE))
                    .thenReturn(Optional.empty());

            assertThatThrownBy(() -> service.creerDemande(req))
                    .isInstanceOf(NotFoundException.class)
                    .hasMessageContaining("Formule introuvable");
        }

        @Test
        @DisplayName("happy path — creates souscription EN_ATTENTE_PAIEMENT")
        void happyPath_createsSouscription() {
            SouscriptionDemandeRequest req = buildRequest(ORG_ID.toString(),
                    "BASIC", "PETITE", "MENSUEL", "ASSOCIATION");

            when(organisationRepo.findByIdOptional(ORG_ID)).thenReturn(Optional.of(org));
            PanacheQuery<SouscriptionOrganisation> q = panacheQueryWithFirstResult(Optional.empty());
            when(souscriptionRepo.find(anyString(), any(UUID.class),
                    eq(StatutValidationSouscription.REJETEE))).thenReturn(q);
            when(formuleRepo.findByCodeAndPlage(TypeFormule.BASIC, PlageMembres.PETITE))
                    .thenReturn(Optional.of(formule));
            doNothing().when(souscriptionRepo).persist(any(SouscriptionOrganisation.class));

            SouscriptionStatutResponse result = service.creerDemande(req);

            assertThat(result).isNotNull();
            assertThat(result.getStatutValidation())
                    .isEqualTo(StatutValidationSouscription.EN_ATTENTE_PAIEMENT.name());
            verify(souscriptionRepo).persist(any(SouscriptionOrganisation.class));
        }

        @Test
        @DisplayName("null typeOrganisation — derives from org entity (ASSOCIATION)")
        void nullTypeOrganisation_derivedFromOrg() {
            SouscriptionDemandeRequest req = buildRequest(ORG_ID.toString(),
                    "BASIC", "PETITE", "MENSUEL", null); // null typeOrganisation

            when(organisationRepo.findByIdOptional(ORG_ID)).thenReturn(Optional.of(org));
            PanacheQuery<SouscriptionOrganisation> q = panacheQueryWithFirstResult(Optional.empty());
            when(souscriptionRepo.find(anyString(), any(UUID.class),
                    eq(StatutValidationSouscription.REJETEE))).thenReturn(q);
            when(formuleRepo.findByCodeAndPlage(TypeFormule.BASIC, PlageMembres.PETITE))
                    .thenReturn(Optional.of(formule));
            doNothing().when(souscriptionRepo).persist(any(SouscriptionOrganisation.class));

            SouscriptionStatutResponse result = service.creerDemande(req);
            assertThat(result).isNotNull();
        }

        @Test
        @DisplayName("null typeOrganisation and null org type — defaults to ASSOCIATION")
        void nullTypeOrganisationAndNullOrgType_defaultsToAssociation() {
            org.setTypeOrganisation(null); // no type on org either
            SouscriptionDemandeRequest req = buildRequest(ORG_ID.toString(),
                    "BASIC", "PETITE", "MENSUEL", null);

            when(organisationRepo.findByIdOptional(ORG_ID)).thenReturn(Optional.of(org));
            PanacheQuery<SouscriptionOrganisation> q = panacheQueryWithFirstResult(Optional.empty());
            when(souscriptionRepo.find(anyString(), any(UUID.class),
                    eq(StatutValidationSouscription.REJETEE))).thenReturn(q);
            when(formuleRepo.findByCodeAndPlage(TypeFormule.BASIC, PlageMembres.PETITE))
                    .thenReturn(Optional.of(formule));
            doNothing().when(souscriptionRepo).persist(any(SouscriptionOrganisation.class));

            SouscriptionStatutResponse result = service.creerDemande(req);
            assertThat(result).isNotNull();
        }

        @Test
        @DisplayName("blank typeOrganisation — defaults to org entity type")
        void blankTypeOrganisation_usesOrgEntity() {
            SouscriptionDemandeRequest req = buildRequest(ORG_ID.toString(),
                    "BASIC", "PETITE", "MENSUEL", "  "); // blank

            when(organisationRepo.findByIdOptional(ORG_ID)).thenReturn(Optional.of(org));
            PanacheQuery<SouscriptionOrganisation> q = panacheQueryWithFirstResult(Optional.empty());
            when(souscriptionRepo.find(anyString(), any(UUID.class),
                    eq(StatutValidationSouscription.REJETEE))).thenReturn(q);
            when(formuleRepo.findByCodeAndPlage(TypeFormule.BASIC, PlageMembres.PETITE))
                    .thenReturn(Optional.of(formule));
            doNothing().when(souscriptionRepo).persist(any(SouscriptionOrganisation.class));

            SouscriptionStatutResponse result = service.creerDemande(req);
            assertThat(result).isNotNull();
        }

        @Test
        @DisplayName("annuel period — calculates correct total with coefficient 0.80 × 12 months")
        void annuelPeriod_correctTotalCalculation() {
            SouscriptionDemandeRequest req = buildRequest(ORG_ID.toString(),
                    "BASIC", "PETITE", "ANNUEL", "ASSOCIATION");

            when(organisationRepo.findByIdOptional(ORG_ID)).thenReturn(Optional.of(org));
            PanacheQuery<SouscriptionOrganisation> q = panacheQueryWithFirstResult(Optional.empty());
            when(souscriptionRepo.find(anyString(), any(UUID.class),
                    eq(StatutValidationSouscription.REJETEE))).thenReturn(q);
            when(formuleRepo.findByCodeAndPlage(TypeFormule.BASIC, PlageMembres.PETITE))
                    .thenReturn(Optional.of(formule));
            doNothing().when(souscriptionRepo).persist(any(SouscriptionOrganisation.class));

            SouscriptionStatutResponse result = service.creerDemande(req);
            // 3000 × 1.0 × 0.80 × 12 = 28800
            assertThat(result.getMontantTotal()).isEqualByComparingTo(new BigDecimal("28800"));
        }

        @Test
        @DisplayName("FEDERATION + PREMIUM — applies premium coefficient 1.5")
        void federationPremium_appliesPremiumCoefficient() {
            FormuleAbonnement premiumFormule = FormuleAbonnement.builder()
                    .code(TypeFormule.PREMIUM)
                    .plage(PlageMembres.PETITE)
                    .libelle("Premium Petite")
                    .prixMensuel(new BigDecimal("10000"))
                    .prixAnnuel(new BigDecimal("96000"))
                    .maxMembres(100)
                    .maxStockageMo(1024)
                    .apiAccess(true)
                    .federationAccess(true)
                    .supportPrioritaire(true)
                    .build();

            SouscriptionDemandeRequest req = buildRequest(ORG_ID.toString(),
                    "PREMIUM", "PETITE", "MENSUEL", "FEDERATION");

            when(organisationRepo.findByIdOptional(ORG_ID)).thenReturn(Optional.of(org));
            PanacheQuery<SouscriptionOrganisation> q = panacheQueryWithFirstResult(Optional.empty());
            when(souscriptionRepo.find(anyString(), any(UUID.class),
                    eq(StatutValidationSouscription.REJETEE))).thenReturn(q);
            when(formuleRepo.findByCodeAndPlage(TypeFormule.PREMIUM, PlageMembres.PETITE))
                    .thenReturn(Optional.of(premiumFormule));
            doNothing().when(souscriptionRepo).persist(any(SouscriptionOrganisation.class));

            SouscriptionStatutResponse result = service.creerDemande(req);
            // 10000 × 1.5 × 1.0 × 1 = 15000
            assertThat(result.getMontantTotal()).isEqualByComparingTo(new BigDecimal("15000"));
        }
    }

    // ─── getSouscription ───────────────────────────────────────────────────────

    @Nested
    @DisplayName("getSouscription")
    class GetSouscriptionTests {

        @Test
        @DisplayName("throws NotFoundException when souscription not found")
        void notFound_throws() {
            when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID)).thenReturn(Optional.empty());

            assertThatThrownBy(() -> service.getSouscription(SOUSCRIPTION_ID))
                    .isInstanceOf(NotFoundException.class)
                    .hasMessageContaining("Souscription introuvable");
        }

        @Test
        @DisplayName("returns mapped response when found")
        void found_returnsMapped() {
            when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID))
                    .thenReturn(Optional.of(souscription));

            SouscriptionStatutResponse result = service.getSouscription(SOUSCRIPTION_ID);

            assertThat(result).isNotNull();
            assertThat(result.getStatutValidation())
                    .isEqualTo(StatutValidationSouscription.EN_ATTENTE_PAIEMENT.name());
        }
    }

    // ─── getMaSouscription ─────────────────────────────────────────────────────

    @Nested
    @DisplayName("getMaSouscription")
    class GetMaSouscriptionTests {

        @Test
        @DisplayName("membre not found — throws NotFoundException")
        void membreNotFound_throws() {
            when(securiteHelper.resolveMembreId()).thenReturn(MEMBRE_ID);
            when(membreRepository.findByIdOptional(MEMBRE_ID)).thenReturn(Optional.empty());

            assertThatThrownBy(() -> service.getMaSouscription())
                    .isInstanceOf(NotFoundException.class)
                    .hasMessageContaining("Membre introuvable");
        }

        @Test
        @DisplayName("no souscription for membre — throws NotFoundException")
        void noSouscription_throws() {
            Membre membre = new Membre();
            membre.setId(MEMBRE_ID);

            when(securiteHelper.resolveMembreId()).thenReturn(MEMBRE_ID);
            when(membreRepository.findByIdOptional(MEMBRE_ID)).thenReturn(Optional.of(membre));

            PanacheQuery<SouscriptionOrganisation> q = panacheQueryWithFirstResult(Optional.empty());
            when(souscriptionRepo.find(anyString(), eq(MEMBRE_ID))).thenReturn(q);

            assertThatThrownBy(() -> service.getMaSouscription())
                    .isInstanceOf(NotFoundException.class)
                    .hasMessageContaining("Aucune souscription");
        }

        @Test
        @DisplayName("returns souscription when found")
        void found_returnsMapped() {
            Membre membre = new Membre();
            membre.setId(MEMBRE_ID);

            when(securiteHelper.resolveMembreId()).thenReturn(MEMBRE_ID);
            when(membreRepository.findByIdOptional(MEMBRE_ID)).thenReturn(Optional.of(membre));

            PanacheQuery<SouscriptionOrganisation> q = panacheQueryWithFirstResult(
                    Optional.of(souscription));
            when(souscriptionRepo.find(anyString(), eq(MEMBRE_ID))).thenReturn(q);

            SouscriptionStatutResponse result = service.getMaSouscription();
            assertThat(result).isNotNull();
        }
    }

    // ─── initierPaiementWave ───────────────────────────────────────────────────

    @Nested
    @DisplayName("initierPaiementWave")
    class InitierPaiementWaveTests {

        @Test
        @DisplayName("souscription not found — throws NotFoundException")
        void notFound_throws() {
            when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID)).thenReturn(Optional.empty());

            assertThatThrownBy(() -> service.initierPaiementWave(SOUSCRIPTION_ID))
                    .isInstanceOf(NotFoundException.class);
        }

        @Test
        @DisplayName("statut cannot initiate payment — throws BadRequestException")
        void cannotInitiatePaiement_throws() {
            souscription.setStatutValidation(StatutValidationSouscription.PAIEMENT_INITIE);
            when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID))
                    .thenReturn(Optional.of(souscription));

            assertThatThrownBy(() -> service.initierPaiementWave(SOUSCRIPTION_ID))
                    .isInstanceOf(BadRequestException.class)
                    .hasMessageContaining("Impossible d'initier le paiement");
        }

        @Test
        @DisplayName("null montant — throws BadRequestException")
        void nullMontant_throws() {
            souscription.setMontantTotal(null);
            when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID))
                    .thenReturn(Optional.of(souscription));

            assertThatThrownBy(() -> service.initierPaiementWave(SOUSCRIPTION_ID))
                    .isInstanceOf(BadRequestException.class)
                    .hasMessageContaining("Montant de souscription invalide");
        }

        @Test
        @DisplayName("zero montant — throws BadRequestException")
        void zeroMontant_throws() {
            souscription.setMontantTotal(BigDecimal.ZERO);
            when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID))
                    .thenReturn(Optional.of(souscription));

            assertThatThrownBy(() -> service.initierPaiementWave(SOUSCRIPTION_ID))
                    .isInstanceOf(BadRequestException.class);
        }

        @Test
        @DisplayName("Wave service throws WaveCheckoutException — wrapped as BadRequestException")
        void waveException_wrappedAsBadRequest() {
            when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID))
                    .thenReturn(Optional.of(souscription));
            when(waveService.createSession(anyString(), anyString(), anyString(),
                    anyString(), anyString(), any()))
                    .thenThrow(new WaveCheckoutService.WaveCheckoutException("Wave error"));

            assertThatThrownBy(() -> service.initierPaiementWave(SOUSCRIPTION_ID))
                    .isInstanceOf(BadRequestException.class)
                    .hasMessageContaining("Erreur de création de session Wave");
        }

        @Test
        @DisplayName("happy path — returns PAIEMENT_INITIE with waveLaunchUrl")
        void happyPath_returnsPaiementInitie() {
            when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID))
                    .thenReturn(Optional.of(souscription));

            WaveCheckoutService.WaveCheckoutSessionResponse session =
                    new WaveCheckoutService.WaveCheckoutSessionResponse("sess-123",
                            "https://pay.wave.com/checkout/sess-123");
            when(waveService.createSession(anyString(), anyString(), anyString(),
                    anyString(), anyString(), any()))
                    .thenReturn(session);
            doNothing().when(souscriptionRepo).persist(any(SouscriptionOrganisation.class));

            SouscriptionStatutResponse result = service.initierPaiementWave(SOUSCRIPTION_ID);

            assertThat(result).isNotNull();
            assertThat(result.getStatutValidation())
                    .isEqualTo(StatutValidationSouscription.PAIEMENT_INITIE.name());
            assertThat(result.getWaveLaunchUrl())
                    .isEqualTo("https://pay.wave.com/checkout/sess-123");
        }
    }

    // ─── confirmerPaiement ──────────────────────────────────────────────────────

    @Nested
    @DisplayName("confirmerPaiement")
    class ConfirmerPaiementTests {

        @Test
        @DisplayName("souscription not found — throws NotFoundException")
        void notFound_throws() {
            when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID)).thenReturn(Optional.empty());

            assertThatThrownBy(() -> service.confirmerPaiement(SOUSCRIPTION_ID, "wave-ref"))
                    .isInstanceOf(NotFoundException.class);
        }

        @Test
        @DisplayName("wrong statut — throws BadRequestException")
        void wrongStatut_throws() {
            souscription.setStatutValidation(StatutValidationSouscription.REJETEE);
            when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID))
                    .thenReturn(Optional.of(souscription));

            assertThatThrownBy(() -> service.confirmerPaiement(SOUSCRIPTION_ID, "wave-ref"))
                    .isInstanceOf(BadRequestException.class)
                    .hasMessageContaining("Impossible de confirmer");
        }

        @Test
        @DisplayName("happy path PAIEMENT_INITIE — confirms and activates admin account")
        void happyPath_paiementInitie_confirmsPayment() {
            souscription.setStatutValidation(StatutValidationSouscription.PAIEMENT_INITIE);

            when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID))
                    .thenReturn(Optional.of(souscription));
            doNothing().when(souscriptionRepo).persist(any(SouscriptionOrganisation.class));
            when(securiteHelper.resolveEmail()).thenReturn("admin@test.com");

            Membre membre = buildMembre(MEMBRE_ID, "admin@test.com", "Jean", "Dupont");
            when(membreRepository.findByEmail("admin@test.com")).thenReturn(Optional.of(membre));
            when(organisationRepo.findByIdOptional(ORG_ID)).thenReturn(Optional.of(org));
            when(membreOrganisationRepository.findByMembreIdAndOrganisationId(MEMBRE_ID, ORG_ID))
                    .thenReturn(Optional.empty());
            doNothing().when(membreService).lierMembreOrganisationEtIncrementerQuota(
                    any(), any(), anyString());
            doNothing().when(membreService).promouvoirAdminOrganisation(any());
            doNothing().when(keycloakSyncService).promouvoirAdminOrganisationDansKeycloak(any());
            doNothing().when(emailTemplateService).envoyerConfirmationSouscription(
                    anyString(), anyString(), anyString(), anyString(), any(), anyString(),
                    any(), any(), any(), any(), anyBoolean(), anyBoolean());

            service.confirmerPaiement(SOUSCRIPTION_ID, "wave-ref-123");

            verify(souscriptionRepo).persist(any(SouscriptionOrganisation.class));
            assertThat(souscription.getStatutValidation())
                    .isEqualTo(StatutValidationSouscription.VALIDEE);
            assertThat(souscription.getStatut()).isEqualTo(StatutSouscription.ACTIVE);
        }

        @Test
        @DisplayName("EN_ATTENTE_PAIEMENT statut — also accepted for confirmation")
        void enAttentePaiement_alsoAccepted() {
            souscription.setStatutValidation(StatutValidationSouscription.EN_ATTENTE_PAIEMENT);

            when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID))
                    .thenReturn(Optional.of(souscription));
            doNothing().when(souscriptionRepo).persist(any(SouscriptionOrganisation.class));
            when(securiteHelper.resolveEmail()).thenReturn(null); // no email → activation skipped

            service.confirmerPaiement(SOUSCRIPTION_ID, "wave-ref-456");

            assertThat(souscription.getStatutValidation())
                    .isEqualTo(StatutValidationSouscription.VALIDEE);
        }

        @Test
        @DisplayName("activerAdmin — null email from securiteHelper — returns early without throwing")
        void nullEmail_activationSkipped() {
            souscription.setStatutValidation(StatutValidationSouscription.PAIEMENT_INITIE);

            when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID))
                    .thenReturn(Optional.of(souscription));
            doNothing().when(souscriptionRepo).persist(any(SouscriptionOrganisation.class));
            when(securiteHelper.resolveEmail()).thenReturn(null);

            // Should not throw
            service.confirmerPaiement(SOUSCRIPTION_ID, "ref");
        }

        @Test
        @DisplayName("activerAdmin — membre not found by email — returns early")
        void memberNotFoundByEmail_activationSkipped() {
            souscription.setStatutValidation(StatutValidationSouscription.PAIEMENT_INITIE);

            when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID))
                    .thenReturn(Optional.of(souscription));
            doNothing().when(souscriptionRepo).persist(any(SouscriptionOrganisation.class));
            when(securiteHelper.resolveEmail()).thenReturn("nobody@test.com");
            when(membreRepository.findByEmail("nobody@test.com")).thenReturn(Optional.empty());

            service.confirmerPaiement(SOUSCRIPTION_ID, "ref");
        }

        @Test
        @DisplayName("activerAdmin — org not found — returns early")
        void orgNotFound_activationSkipped() {
            souscription.setStatutValidation(StatutValidationSouscription.PAIEMENT_INITIE);

            when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID))
                    .thenReturn(Optional.of(souscription));
            doNothing().when(souscriptionRepo).persist(any(SouscriptionOrganisation.class));
            when(securiteHelper.resolveEmail()).thenReturn("admin@test.com");
            Membre m = buildMembre(MEMBRE_ID, "admin@test.com", "Jean", "Dupont");
            when(membreRepository.findByEmail("admin@test.com")).thenReturn(Optional.of(m));
            when(organisationRepo.findByIdOptional(ORG_ID)).thenReturn(Optional.empty());

            service.confirmerPaiement(SOUSCRIPTION_ID, "ref");
        }

        @Test
        @DisplayName("activerAdmin — lien existant INACTIF — activated to ACTIF")
        void lienExistantInactif_activated() {
            souscription.setStatutValidation(StatutValidationSouscription.PAIEMENT_INITIE);

            when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID))
                    .thenReturn(Optional.of(souscription));
            doNothing().when(souscriptionRepo).persist(any(SouscriptionOrganisation.class));
            when(securiteHelper.resolveEmail()).thenReturn("admin@test.com");

            Membre membre = buildMembre(MEMBRE_ID, "admin@test.com", "Jean", "Dupont");
            when(membreRepository.findByEmail("admin@test.com")).thenReturn(Optional.of(membre));
            when(organisationRepo.findByIdOptional(ORG_ID)).thenReturn(Optional.of(org));

            MembreOrganisation lien = new MembreOrganisation();
            lien.setStatutMembre(StatutMembre.SUSPENDU); // not ACTIF
            when(membreOrganisationRepository.findByMembreIdAndOrganisationId(MEMBRE_ID, ORG_ID))
                    .thenReturn(Optional.of(lien));

            doNothing().when(membreService).promouvoirAdminOrganisation(any());
            doNothing().when(keycloakSyncService).promouvoirAdminOrganisationDansKeycloak(any());
            when(securiteHelper.resolveEmail()).thenReturn(null); // email call for email notification

            // Reload mock after first resolveEmail() returns null for notification
            when(securiteHelper.resolveEmail())
                    .thenReturn("admin@test.com") // first call in activerAdmin
                    .thenReturn(null); // second call in envoyerEmailSouscriptionActive

            service.confirmerPaiement(SOUSCRIPTION_ID, "ref");

            assertThat(lien.getStatutMembre()).isEqualTo(StatutMembre.ACTIF);
        }

        @Test
        @DisplayName("activerAdmin — lien existant ACTIF — statut unchanged")
        void lienExistantActif_unchanged() {
            souscription.setStatutValidation(StatutValidationSouscription.PAIEMENT_INITIE);

            when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID))
                    .thenReturn(Optional.of(souscription));
            doNothing().when(souscriptionRepo).persist(any(SouscriptionOrganisation.class));

            Membre membre = buildMembre(MEMBRE_ID, "admin@test.com", "Jean", "Dupont");

            MembreOrganisation lien = new MembreOrganisation();
            lien.setStatutMembre(StatutMembre.ACTIF); // already ACTIF

            when(securiteHelper.resolveEmail())
                    .thenReturn("admin@test.com")
                    .thenReturn(null);
            when(membreRepository.findByEmail("admin@test.com")).thenReturn(Optional.of(membre));
            when(organisationRepo.findByIdOptional(ORG_ID)).thenReturn(Optional.of(org));
            when(membreOrganisationRepository.findByMembreIdAndOrganisationId(MEMBRE_ID, ORG_ID))
                    .thenReturn(Optional.of(lien));
            doNothing().when(membreService).promouvoirAdminOrganisation(any());
            doNothing().when(keycloakSyncService).promouvoirAdminOrganisationDansKeycloak(any());

            service.confirmerPaiement(SOUSCRIPTION_ID, "ref");

            assertThat(lien.getStatutMembre()).isEqualTo(StatutMembre.ACTIF);
        }

        @Test
        @DisplayName("activation DB exception — caught, souscription remains VALIDEE")
        void dbActivationException_caught() {
            souscription.setStatutValidation(StatutValidationSouscription.PAIEMENT_INITIE);

            when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID))
                    .thenReturn(Optional.of(souscription));
            doNothing().when(souscriptionRepo).persist(any(SouscriptionOrganisation.class));

            Membre membre = buildMembre(MEMBRE_ID, "admin@test.com", "Jean", "Dupont");
            when(securiteHelper.resolveEmail())
                    .thenReturn("admin@test.com")
                    .thenReturn(null);
            when(membreRepository.findByEmail("admin@test.com")).thenReturn(Optional.of(membre));
            when(organisationRepo.findByIdOptional(ORG_ID)).thenReturn(Optional.of(org));
            when(membreOrganisationRepository.findByMembreIdAndOrganisationId(MEMBRE_ID, ORG_ID))
                    .thenReturn(Optional.empty());
            doNothing().when(membreService).lierMembreOrganisationEtIncrementerQuota(
                    any(), any(), anyString());
            doThrow(new RuntimeException("DB error")).when(membreService)
                    .promouvoirAdminOrganisation(any());

            // Should not throw — exception is caught
            service.confirmerPaiement(SOUSCRIPTION_ID, "ref");
            assertThat(souscription.getStatutValidation())
                    .isEqualTo(StatutValidationSouscription.VALIDEE);
        }

        @Test
        @DisplayName("Keycloak sync exception — caught (non-blocking)")
        void keycloakSyncException_caught() {
            souscription.setStatutValidation(StatutValidationSouscription.PAIEMENT_INITIE);

            when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID))
                    .thenReturn(Optional.of(souscription));
            doNothing().when(souscriptionRepo).persist(any(SouscriptionOrganisation.class));

            Membre membre = buildMembre(MEMBRE_ID, "admin@test.com", "Jean", "Dupont");
            when(securiteHelper.resolveEmail())
                    .thenReturn("admin@test.com")
                    .thenReturn(null);
            when(membreRepository.findByEmail("admin@test.com")).thenReturn(Optional.of(membre));
            when(organisationRepo.findByIdOptional(ORG_ID)).thenReturn(Optional.of(org));
            when(membreOrganisationRepository.findByMembreIdAndOrganisationId(MEMBRE_ID, ORG_ID))
                    .thenReturn(Optional.empty());
            doNothing().when(membreService).lierMembreOrganisationEtIncrementerQuota(
                    any(), any(), anyString());
            doNothing().when(membreService).promouvoirAdminOrganisation(any());
            doThrow(new RuntimeException("Keycloak unavailable")).when(keycloakSyncService)
                    .promouvoirAdminOrganisationDansKeycloak(any());

            service.confirmerPaiement(SOUSCRIPTION_ID, "ref");
            // Should not throw
        }
    }

    // ─── listerToutes ─────────────────────────────────────────────────────────

    @Nested
    @DisplayName("listerToutes")
    class ListerToutesTests {

        @Test
        @DisplayName("with organisationId filter — returns filtered list")
        void withOrgFilter_returnsFiltered() {
            PanacheQuery<SouscriptionOrganisation> q = panacheQueryWithList(List.of(souscription));
            when(souscriptionRepo.find(anyString(), eq(ORG_ID))).thenReturn(q);

            List<SouscriptionStatutResponse> result = service.listerToutes(ORG_ID, 0, 10);
            assertThat(result).hasSize(1);
        }

        @Test
        @DisplayName("without organisationId filter — returns all")
        void withoutOrgFilter_returnsAll() {
            PanacheQuery<SouscriptionOrganisation> q = panacheQueryWithList(List.of(souscription));
            when(souscriptionRepo.findAll()).thenReturn(q);

            List<SouscriptionStatutResponse> result = service.listerToutes(null, 0, 10);
            assertThat(result).hasSize(1);
        }
    }

    // ─── obtenirActiveParOrganisation ─────────────────────────────────────────

    @Test
    @DisplayName("obtenirActiveParOrganisation — found — returns response")
    void obtenirActiveParOrganisation_found() {
        PanacheQuery<SouscriptionOrganisation> q = panacheQueryWithFirstResult(
                Optional.of(souscription));
        when(souscriptionRepo.find(anyString(), eq(ORG_ID),
                eq(StatutSouscription.ACTIVE))).thenReturn(q);

        SouscriptionStatutResponse result = service.obtenirActiveParOrganisation(ORG_ID);
        assertThat(result).isNotNull();
    }

    @Test
    @DisplayName("obtenirActiveParOrganisation — not found — returns null")
    void obtenirActiveParOrganisation_notFound_returnsNull() {
        PanacheQuery<SouscriptionOrganisation> q = panacheQueryWithFirstResult(Optional.empty());
        when(souscriptionRepo.find(anyString(), eq(ORG_ID),
                eq(StatutSouscription.ACTIVE))).thenReturn(q);

        SouscriptionStatutResponse result = service.obtenirActiveParOrganisation(ORG_ID);
        assertThat(result).isNull();
    }

    // ─── getSouscriptionsEnAttenteValidation ──────────────────────────────────

    @Test
    @DisplayName("getSouscriptionsEnAttenteValidation — returns list")
    void getSouscriptionsEnAttenteValidation_returnsList() {
        PanacheQuery<SouscriptionOrganisation> q = panacheQueryWithList(List.of(souscription));
        when(souscriptionRepo.find(anyString(),
                eq(StatutValidationSouscription.PAIEMENT_CONFIRME))).thenReturn(q);

        List<SouscriptionStatutResponse> result = service.getSouscriptionsEnAttenteValidation();
        assertThat(result).hasSize(1);
    }

    // ─── approuver ────────────────────────────────────────────────────────────

    @Nested
    @DisplayName("approuver")
    class ApprouverTests {

        @Test
        @DisplayName("souscription not found — throws NotFoundException")
        void notFound_throws() {
            when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID)).thenReturn(Optional.empty());

            assertThatThrownBy(() -> service.approuver(SOUSCRIPTION_ID, SUPER_ADMIN_ID))
                    .isInstanceOf(NotFoundException.class);
        }

        @Test
        @DisplayName("wrong statut (e.g. EN_ATTENTE_PAIEMENT) — throws BadRequestException")
        void wrongStatut_throws() {
            souscription.setStatutValidation(StatutValidationSouscription.EN_ATTENTE_PAIEMENT);
            when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID))
                    .thenReturn(Optional.of(souscription));

            assertThatThrownBy(() -> service.approuver(SOUSCRIPTION_ID, SUPER_ADMIN_ID))
                    .isInstanceOf(BadRequestException.class)
                    .hasMessageContaining("Impossible d'approuver");
        }

        @Test
        @DisplayName("already VALIDEE — skips without doing anything (early return)")
        void alreadyValidee_skips() {
            souscription.setStatutValidation(StatutValidationSouscription.VALIDEE);
            when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID))
                    .thenReturn(Optional.of(souscription));

            service.approuver(SOUSCRIPTION_ID, SUPER_ADMIN_ID);

            // persist should NOT be called since we return early
            verify(souscriptionRepo, never()).persist(any(SouscriptionOrganisation.class));
        }

        @Test
        @DisplayName("PAIEMENT_CONFIRME statut — approves and activates")
        void paiementConfirme_approvesAndActivates() {
            souscription.setStatutValidation(StatutValidationSouscription.PAIEMENT_CONFIRME);

            when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID))
                    .thenReturn(Optional.of(souscription));
            doNothing().when(souscriptionRepo).persist(any(SouscriptionOrganisation.class));
            when(securiteHelper.resolveEmail()).thenReturn(null); // skip activation

            service.approuver(SOUSCRIPTION_ID, SUPER_ADMIN_ID);

            assertThat(souscription.getStatutValidation())
                    .isEqualTo(StatutValidationSouscription.VALIDEE);
            assertThat(souscription.getStatut()).isEqualTo(StatutSouscription.ACTIVE);
            assertThat(souscription.getValidatedById()).isEqualTo(SUPER_ADMIN_ID);
            verify(souscriptionRepo).persist(any(SouscriptionOrganisation.class));
        }
    }

    // ─── rejeter ──────────────────────────────────────────────────────────────

    @Nested
    @DisplayName("rejeter")
    class RejeterTests {

        @Test
        @DisplayName("null commentaire — throws BadRequestException")
        void nullCommentaire_throws() {
            assertThatThrownBy(() -> service.rejeter(SOUSCRIPTION_ID, SUPER_ADMIN_ID, null))
                    .isInstanceOf(BadRequestException.class)
                    .hasMessageContaining("commentaire de rejet est obligatoire");
        }

        @Test
        @DisplayName("blank commentaire — throws BadRequestException")
        void blankCommentaire_throws() {
            assertThatThrownBy(() -> service.rejeter(SOUSCRIPTION_ID, SUPER_ADMIN_ID, "  "))
                    .isInstanceOf(BadRequestException.class);
        }

        @Test
        @DisplayName("souscription not found — throws NotFoundException")
        void notFound_throws() {
            when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID)).thenReturn(Optional.empty());

            assertThatThrownBy(() -> service.rejeter(SOUSCRIPTION_ID, SUPER_ADMIN_ID, "motif"))
                    .isInstanceOf(NotFoundException.class);
        }

        @Test
        @DisplayName("already terminal statut — throws BadRequestException")
        void terminalStatut_throws() {
            souscription.setStatutValidation(StatutValidationSouscription.VALIDEE);
            when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID))
                    .thenReturn(Optional.of(souscription));

            assertThatThrownBy(() -> service.rejeter(SOUSCRIPTION_ID, SUPER_ADMIN_ID, "motif"))
                    .isInstanceOf(BadRequestException.class)
                    .hasMessageContaining("état terminal");
        }

        @Test
        @DisplayName("happy path — rejects and persists")
        void happyPath_rejects() {
            when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID))
                    .thenReturn(Optional.of(souscription));
            doNothing().when(souscriptionRepo).persist(any(SouscriptionOrganisation.class));

            service.rejeter(SOUSCRIPTION_ID, SUPER_ADMIN_ID, "Dossier incomplet");

            assertThat(souscription.getStatutValidation())
                    .isEqualTo(StatutValidationSouscription.REJETEE);
            assertThat(souscription.getStatut()).isEqualTo(StatutSouscription.RESILIEE);
            assertThat(souscription.getCommentaireRejet()).isEqualTo("Dossier incomplet");
            assertThat(souscription.getValidatedById()).isEqualTo(SUPER_ADMIN_ID);
        }

        @Test
        @DisplayName("commentaire > 500 chars — truncated to 500")
        void longCommentaire_truncated() {
            String longComment = "A".repeat(600);
            when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID))
                    .thenReturn(Optional.of(souscription));
            doNothing().when(souscriptionRepo).persist(any(SouscriptionOrganisation.class));

            service.rejeter(SOUSCRIPTION_ID, SUPER_ADMIN_ID, longComment);

            assertThat(souscription.getCommentaireRejet()).hasSize(500);
        }

        @Test
        @DisplayName("REJETEE terminal — also blocked from rejection")
        void rejeteTerminal_throws() {
            souscription.setStatutValidation(StatutValidationSouscription.REJETEE);
            when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID))
                    .thenReturn(Optional.of(souscription));

            assertThatThrownBy(() -> service.rejeter(SOUSCRIPTION_ID, SUPER_ADMIN_ID, "motif"))
                    .isInstanceOf(BadRequestException.class);
        }
    }

    // ─── toStatutResponse — coverage of null branches ─────────────────────────

    @Test
    @DisplayName("toStatutResponse — all nullable fields are null — no NPE")
    void toStatutResponse_allNullFields_noNpe() {
        SouscriptionOrganisation s = new SouscriptionOrganisation();
        s.setId(null);
        s.setOrganisation(null);
        s.setFormule(null);
        s.setStatutValidation(null);
        s.setStatut(null);
        s.setPlage(null);
        s.setTypePeriode(null);
        s.setTypeOrganisationSouscription(null);
        s.setQuotaMax(null);
        s.setQuotaUtilise(null);
        s.setDateFin(null);

        when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID)).thenReturn(Optional.of(s));

        SouscriptionStatutResponse result = service.getSouscription(SOUSCRIPTION_ID);
        assertThat(result).isNotNull();
        assertThat(result.getSouscriptionId()).isNull();
        assertThat(result.getStatutValidation()).isNull();
        assertThat(result.getOrganisationId()).isNull();
    }

    @Test
    @DisplayName("toStatutResponse — quotaMax and quotaUtilise set — quotaRestant computed")
    void toStatutResponse_quotaFields_computed() {
        souscription.setQuotaMax(100);
        souscription.setQuotaUtilise(30);

        when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID)).thenReturn(Optional.of(souscription));

        SouscriptionStatutResponse result = service.getSouscription(SOUSCRIPTION_ID);
        assertThat(result.getQuotaRestant()).isEqualTo(70);
    }

    @Test
    @DisplayName("toStatutResponse — waveLaunchUrl prefers parameter over stored url")
    void toStatutResponse_waveLaunchUrl_prefersParam() {
        souscription.setWaveCheckoutUrl("https://stored-url.com");
        when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID)).thenReturn(Optional.of(souscription));

        // getSouscription calls toStatutResponse with waveLaunchUrl=null → uses stored url
        SouscriptionStatutResponse result = service.getSouscription(SOUSCRIPTION_ID);
        assertThat(result.getWaveLaunchUrl()).isEqualTo("https://stored-url.com");
    }

    // ─── envoyerEmailSouscriptionActive — exception caught (non-blocking) ──────

    @Test
    @DisplayName("email exception during confirmerPaiement — caught, does not throw")
    void emailException_caughtDuringConfirmerPaiement() {
        souscription.setStatutValidation(StatutValidationSouscription.PAIEMENT_INITIE);

        when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID))
                .thenReturn(Optional.of(souscription));
        doNothing().when(souscriptionRepo).persist(any(SouscriptionOrganisation.class));

        // resolveEmail for activerAdmin returns null (skip activation)
        // then for email notification throws
        when(securiteHelper.resolveEmail())
                .thenReturn(null)  // activerAdmin → skip
                .thenReturn("admin@test.com"); // envoyerEmail
        Membre membre = buildMembre(MEMBRE_ID, "admin@test.com", "Jean", "Dupont");
        when(membreRepository.findByEmail("admin@test.com")).thenReturn(Optional.of(membre));
        doThrow(new RuntimeException("SMTP error")).when(emailTemplateService)
                .envoyerConfirmationSouscription(anyString(), anyString(), anyString(),
                        anyString(), any(), anyString(), any(), any(), any(), any(),
                        anyBoolean(), anyBoolean());

        // Should not throw — email exception is caught
        service.confirmerPaiement(SOUSCRIPTION_ID, "ref");
    }

    // ─── Helpers ──────────────────────────────────────────────────────────────

    private Membre buildMembre(UUID id, String email, String prenom, String nom) {
        Membre m = new Membre();
        m.setId(id);
        m.setEmail(email);
        m.setPrenom(prenom);
        m.setNom(nom);
        return m;
    }
}
