package dev.lions.unionflow.server.service.mutuelle;

import dev.lions.unionflow.server.api.enums.mutuelle.epargne.StatutCompteEpargne;
import dev.lions.unionflow.server.api.enums.mutuelle.epargne.TypeCompteEpargne;
import dev.lions.unionflow.server.api.enums.mutuelle.parts.TypeTransactionPartsSociales;
import dev.lions.unionflow.server.entity.Membre;
import dev.lions.unionflow.server.entity.Organisation;
import dev.lions.unionflow.server.entity.mutuelle.epargne.CompteEpargne;
import dev.lions.unionflow.server.entity.mutuelle.epargne.TransactionEpargne;
import dev.lions.unionflow.server.entity.mutuelle.parts.ComptePartsSociales;
import dev.lions.unionflow.server.entity.mutuelle.parts.TransactionPartsSociales;
import dev.lions.unionflow.server.api.enums.mutuelle.epargne.TypeTransactionEpargne;
import dev.lions.unionflow.server.repository.mutuelle.epargne.CompteEpargneRepository;
import dev.lions.unionflow.server.repository.mutuelle.epargne.TransactionEpargneRepository;
import dev.lions.unionflow.server.repository.mutuelle.parts.ComptePartsSocialesRepository;
import dev.lions.unionflow.server.repository.mutuelle.parts.TransactionPartsSocialesRepository;
import io.quarkus.hibernate.orm.panache.PanacheQuery;
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.time.LocalDateTime;
import java.util.ArrayList;
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.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
@DisplayName("Tests ReleveComptePdfService")
class ReleveComptePdfServiceTest {

    @Mock CompteEpargneRepository compteEpargneRepository;
    @Mock TransactionEpargneRepository transactionEpargneRepository;
    @Mock ComptePartsSocialesRepository comptePartsSocialesRepository;
    @Mock TransactionPartsSocialesRepository transactionPartsSocialesRepository;

    @InjectMocks
    ReleveComptePdfService service;

    private static final UUID COMPTE_ID = UUID.randomUUID();
    private static final LocalDate DATE_DEBUT = LocalDate.of(2026, 1, 1);
    private static final LocalDate DATE_FIN = LocalDate.of(2026, 3, 31);

    private Organisation org;
    private Membre membre;

    @BeforeEach
    void setUp() {
        org = new Organisation();
        org.setNom("Mutuelle Test");

        membre = new Membre();
        membre.setNom("Dupont");
        membre.setPrenom("Jean");
    }

    // ─── Builder helpers ────────────────────────────────────────────────────────

    private CompteEpargne buildCompteEpargne(boolean withOrg, boolean withMembre) {
        CompteEpargne compte = new CompteEpargne();
        compte.setId(COMPTE_ID);
        compte.setNumeroCompte("CEP-001");
        compte.setTypeCompte(TypeCompteEpargne.EPARGNE_LIBRE);
        compte.setSoldeActuel(new BigDecimal("50000"));
        compte.setSoldeBloque(BigDecimal.ZERO);
        compte.setStatut(StatutCompteEpargne.ACTIF);
        compte.setOrganisation(withOrg ? org : null);
        compte.setMembre(withMembre ? membre : null);
        return compte;
    }

    private TransactionEpargne buildTxEpargne(TypeTransactionEpargne type,
                                               BigDecimal montant,
                                               LocalDateTime dateTransaction) {
        TransactionEpargne tx = new TransactionEpargne();
        tx.setType(type);
        tx.setMontant(montant);
        tx.setDateTransaction(dateTransaction);
        tx.setSoldeAvant(new BigDecimal("40000"));
        tx.setSoldeApres(new BigDecimal("50000"));
        tx.setMotif("Test motif");
        return tx;
    }

    private ComptePartsSociales buildComptePartsSociales(boolean withOrg, boolean withMembre) {
        ComptePartsSociales compte = new ComptePartsSociales();
        compte.setId(COMPTE_ID);
        compte.setNumeroCompte("CPS-001");
        compte.setNombreParts(10);
        compte.setValeurNominale(new BigDecimal("5000"));
        compte.setMontantTotal(new BigDecimal("50000"));
        compte.setTotalDividendesRecus(new BigDecimal("2000"));
        compte.setOrganisation(withOrg ? org : null);
        compte.setMembre(withMembre ? membre : null);
        return compte;
    }

    private TransactionPartsSociales buildTxParts(TypeTransactionPartsSociales type,
                                                    BigDecimal montant,
                                                    LocalDateTime dateTransaction) {
        TransactionPartsSociales tx = new TransactionPartsSociales();
        tx.setTypeTransaction(type);
        tx.setMontant(montant);
        tx.setNombreParts(5);
        tx.setSoldePartsAvant(5);
        tx.setSoldePartsApres(10);
        tx.setDateTransaction(dateTransaction);
        tx.setMotif(null); // will use type.getLibelle()
        return tx;
    }

    @SuppressWarnings("unchecked")
    private PanacheQuery<TransactionEpargne> mockEpargneQuery(List<TransactionEpargne> txs) {
        PanacheQuery<TransactionEpargne> q = mock(PanacheQuery.class);
        when(q.list()).thenReturn(txs);
        return q;
    }

    // ─── genererReleveEpargne ─────────────────────────────────────────────────

    @Nested
    @DisplayName("genererReleveEpargne")
    class GenererReleveEpargneTests {

        @Test
        @DisplayName("throws NotFoundException when compte not found")
        void compteNotFound_throws() {
            when(compteEpargneRepository.findByIdOptional(COMPTE_ID)).thenReturn(Optional.empty());

            assertThatThrownBy(() -> service.genererReleveEpargne(COMPTE_ID, DATE_DEBUT, DATE_FIN))
                    .isInstanceOf(NotFoundException.class)
                    .hasMessageContaining("Compte épargne introuvable");
        }

        @Test
        @DisplayName("no transactions — returns PDF with solde ouverture = soldeActuel")
        void noTransactions_returnsPdfWithSoldeActuel() {
            CompteEpargne compte = buildCompteEpargne(true, true);

            when(compteEpargneRepository.findByIdOptional(COMPTE_ID))
                    .thenReturn(Optional.of(compte));
            when(transactionEpargneRepository.find(anyString(), eq(COMPTE_ID)))
                    .thenReturn(mockEpargneQuery(Collections.emptyList()));

            byte[] result = service.genererReleveEpargne(COMPTE_ID, DATE_DEBUT, DATE_FIN);
            assertThat(result).isNotNull().isNotEmpty();
        }

        @Test
        @DisplayName("DEPOT transaction — isDebitEpargne returns false — montant in credit column")
        void depotTransaction_isCredit() {
            CompteEpargne compte = buildCompteEpargne(true, true);
            TransactionEpargne tx = buildTxEpargne(TypeTransactionEpargne.DEPOT,
                    new BigDecimal("10000"),
                    LocalDateTime.of(2026, 1, 15, 10, 0));

            when(compteEpargneRepository.findByIdOptional(COMPTE_ID))
                    .thenReturn(Optional.of(compte));
            when(transactionEpargneRepository.find(anyString(), eq(COMPTE_ID)))
                    .thenReturn(mockEpargneQuery(List.of(tx)));

            byte[] result = service.genererReleveEpargne(COMPTE_ID, DATE_DEBUT, DATE_FIN);
            assertThat(result).isNotNull().isNotEmpty();
        }

        @Test
        @DisplayName("RETRAIT transaction — isDebitEpargne returns true — montant in debit column")
        void retraitTransaction_isDebit() {
            CompteEpargne compte = buildCompteEpargne(true, true);
            TransactionEpargne tx = buildTxEpargne(TypeTransactionEpargne.RETRAIT,
                    new BigDecimal("5000"),
                    LocalDateTime.of(2026, 1, 20, 14, 30));

            when(compteEpargneRepository.findByIdOptional(COMPTE_ID))
                    .thenReturn(Optional.of(compte));
            when(transactionEpargneRepository.find(anyString(), eq(COMPTE_ID)))
                    .thenReturn(mockEpargneQuery(List.of(tx)));

            byte[] result = service.genererReleveEpargne(COMPTE_ID, DATE_DEBUT, DATE_FIN);
            assertThat(result).isNotNull().isNotEmpty();
        }

        @Test
        @DisplayName("PRELEVEMENT_FRAIS — isDebitEpargne true")
        void prelevementFrais_isDebit() {
            CompteEpargne compte = buildCompteEpargne(true, true);
            TransactionEpargne tx = buildTxEpargne(TypeTransactionEpargne.PRELEVEMENT_FRAIS,
                    new BigDecimal("500"), LocalDateTime.now());

            when(compteEpargneRepository.findByIdOptional(COMPTE_ID))
                    .thenReturn(Optional.of(compte));
            when(transactionEpargneRepository.find(anyString(), eq(COMPTE_ID)))
                    .thenReturn(mockEpargneQuery(List.of(tx)));

            byte[] result = service.genererReleveEpargne(COMPTE_ID, DATE_DEBUT, DATE_FIN);
            assertThat(result).isNotNull().isNotEmpty();
        }

        @Test
        @DisplayName("TRANSFERT_SORTANT — isDebitEpargne true")
        void transfertSortant_isDebit() {
            CompteEpargne compte = buildCompteEpargne(true, true);
            TransactionEpargne tx = buildTxEpargne(TypeTransactionEpargne.TRANSFERT_SORTANT,
                    new BigDecimal("3000"), LocalDateTime.now());

            when(compteEpargneRepository.findByIdOptional(COMPTE_ID))
                    .thenReturn(Optional.of(compte));
            when(transactionEpargneRepository.find(anyString(), eq(COMPTE_ID)))
                    .thenReturn(mockEpargneQuery(List.of(tx)));

            byte[] result = service.genererReleveEpargne(COMPTE_ID, DATE_DEBUT, DATE_FIN);
            assertThat(result).isNotNull().isNotEmpty();
        }

        @Test
        @DisplayName("REMBOURSEMENT_CREDIT — isDebitEpargne true")
        void remboursementCredit_isDebit() {
            CompteEpargne compte = buildCompteEpargne(true, true);
            TransactionEpargne tx = buildTxEpargne(TypeTransactionEpargne.REMBOURSEMENT_CREDIT,
                    new BigDecimal("8000"), LocalDateTime.now());

            when(compteEpargneRepository.findByIdOptional(COMPTE_ID))
                    .thenReturn(Optional.of(compte));
            when(transactionEpargneRepository.find(anyString(), eq(COMPTE_ID)))
                    .thenReturn(mockEpargneQuery(List.of(tx)));

            byte[] result = service.genererReleveEpargne(COMPTE_ID, DATE_DEBUT, DATE_FIN);
            assertThat(result).isNotNull().isNotEmpty();
        }

        @Test
        @DisplayName("RETENUE_GARANTIE — isDebitEpargne true")
        void retenueGarantie_isDebit() {
            CompteEpargne compte = buildCompteEpargne(true, true);
            TransactionEpargne tx = buildTxEpargne(TypeTransactionEpargne.RETENUE_GARANTIE,
                    new BigDecimal("2000"), LocalDateTime.now());

            when(compteEpargneRepository.findByIdOptional(COMPTE_ID))
                    .thenReturn(Optional.of(compte));
            when(transactionEpargneRepository.find(anyString(), eq(COMPTE_ID)))
                    .thenReturn(mockEpargneQuery(List.of(tx)));

            byte[] result = service.genererReleveEpargne(COMPTE_ID, DATE_DEBUT, DATE_FIN);
            assertThat(result).isNotNull().isNotEmpty();
        }

        @Test
        @DisplayName("LIBERATION_GARANTIE — isDebitEpargne false (credit)")
        void liberationGarantie_isCredit() {
            CompteEpargne compte = buildCompteEpargne(true, true);
            TransactionEpargne tx = buildTxEpargne(TypeTransactionEpargne.LIBERATION_GARANTIE,
                    new BigDecimal("2000"), LocalDateTime.now());

            when(compteEpargneRepository.findByIdOptional(COMPTE_ID))
                    .thenReturn(Optional.of(compte));
            when(transactionEpargneRepository.find(anyString(), eq(COMPTE_ID)))
                    .thenReturn(mockEpargneQuery(List.of(tx)));

            byte[] result = service.genererReleveEpargne(COMPTE_ID, DATE_DEBUT, DATE_FIN);
            assertThat(result).isNotNull().isNotEmpty();
        }

        @Test
        @DisplayName("PAIEMENT_INTERETS — isDebitEpargne false (credit)")
        void paiementInterets_isCredit() {
            CompteEpargne compte = buildCompteEpargne(true, true);
            TransactionEpargne tx = buildTxEpargne(TypeTransactionEpargne.PAIEMENT_INTERETS,
                    new BigDecimal("1500"), LocalDateTime.now());

            when(compteEpargneRepository.findByIdOptional(COMPTE_ID))
                    .thenReturn(Optional.of(compte));
            when(transactionEpargneRepository.find(anyString(), eq(COMPTE_ID)))
                    .thenReturn(mockEpargneQuery(List.of(tx)));

            byte[] result = service.genererReleveEpargne(COMPTE_ID, DATE_DEBUT, DATE_FIN);
            assertThat(result).isNotNull().isNotEmpty();
        }

        @Test
        @DisplayName("TRANSFERT_ENTRANT — isDebitEpargne false (credit)")
        void transfertEntrant_isCredit() {
            CompteEpargne compte = buildCompteEpargne(true, true);
            TransactionEpargne tx = buildTxEpargne(TypeTransactionEpargne.TRANSFERT_ENTRANT,
                    new BigDecimal("7000"), LocalDateTime.now());

            when(compteEpargneRepository.findByIdOptional(COMPTE_ID))
                    .thenReturn(Optional.of(compte));
            when(transactionEpargneRepository.find(anyString(), eq(COMPTE_ID)))
                    .thenReturn(mockEpargneQuery(List.of(tx)));

            byte[] result = service.genererReleveEpargne(COMPTE_ID, DATE_DEBUT, DATE_FIN);
            assertThat(result).isNotNull().isNotEmpty();
        }

        @Test
        @DisplayName("null motif — uses type.name() as fallback")
        void nullMotif_usesTypeName() {
            CompteEpargne compte = buildCompteEpargne(true, true);
            TransactionEpargne tx = buildTxEpargne(TypeTransactionEpargne.DEPOT,
                    new BigDecimal("1000"), LocalDateTime.now());
            tx.setMotif(null);

            when(compteEpargneRepository.findByIdOptional(COMPTE_ID))
                    .thenReturn(Optional.of(compte));
            when(transactionEpargneRepository.find(anyString(), eq(COMPTE_ID)))
                    .thenReturn(mockEpargneQuery(List.of(tx)));

            byte[] result = service.genererReleveEpargne(COMPTE_ID, DATE_DEBUT, DATE_FIN);
            assertThat(result).isNotNull().isNotEmpty();
        }

        @Test
        @DisplayName("null organisation — uses 'UnionFlow' as org name fallback")
        void nullOrganisation_usesUnionFlowFallback() {
            CompteEpargne compte = buildCompteEpargne(false, true); // no org

            when(compteEpargneRepository.findByIdOptional(COMPTE_ID))
                    .thenReturn(Optional.of(compte));
            when(transactionEpargneRepository.find(anyString(), eq(COMPTE_ID)))
                    .thenReturn(mockEpargneQuery(Collections.emptyList()));

            byte[] result = service.genererReleveEpargne(COMPTE_ID, DATE_DEBUT, DATE_FIN);
            assertThat(result).isNotNull().isNotEmpty();
        }

        @Test
        @DisplayName("null membre — empty string for titulaire")
        void nullMembre_emptyTitulaire() {
            CompteEpargne compte = buildCompteEpargne(true, false); // no membre

            when(compteEpargneRepository.findByIdOptional(COMPTE_ID))
                    .thenReturn(Optional.of(compte));
            when(transactionEpargneRepository.find(anyString(), eq(COMPTE_ID)))
                    .thenReturn(mockEpargneQuery(Collections.emptyList()));

            byte[] result = service.genererReleveEpargne(COMPTE_ID, DATE_DEBUT, DATE_FIN);
            assertThat(result).isNotNull().isNotEmpty();
        }

        @Test
        @DisplayName("dateDebut filter — transactions before dateDebut excluded")
        void dateDebutFilter_excludesOlderTx() {
            CompteEpargne compte = buildCompteEpargne(true, true);

            // Transaction before dateDebut — should be filtered out
            TransactionEpargne txBefore = buildTxEpargne(TypeTransactionEpargne.DEPOT,
                    new BigDecimal("1000"),
                    LocalDateTime.of(2025, 12, 31, 12, 0));
            // Transaction within range
            TransactionEpargne txIn = buildTxEpargne(TypeTransactionEpargne.DEPOT,
                    new BigDecimal("2000"),
                    LocalDateTime.of(2026, 1, 15, 12, 0));

            when(compteEpargneRepository.findByIdOptional(COMPTE_ID))
                    .thenReturn(Optional.of(compte));
            when(transactionEpargneRepository.find(anyString(), eq(COMPTE_ID)))
                    .thenReturn(mockEpargneQuery(List.of(txBefore, txIn)));

            byte[] result = service.genererReleveEpargne(COMPTE_ID, DATE_DEBUT, DATE_FIN);
            assertThat(result).isNotNull().isNotEmpty();
        }

        @Test
        @DisplayName("dateFin filter — transactions after dateFin excluded")
        void dateFinFilter_excludesNewerTx() {
            CompteEpargne compte = buildCompteEpargne(true, true);

            // Transaction after dateFin
            TransactionEpargne txAfter = buildTxEpargne(TypeTransactionEpargne.DEPOT,
                    new BigDecimal("1000"),
                    LocalDateTime.of(2026, 4, 10, 12, 0));
            // Transaction within range
            TransactionEpargne txIn = buildTxEpargne(TypeTransactionEpargne.DEPOT,
                    new BigDecimal("2000"),
                    LocalDateTime.of(2026, 2, 15, 12, 0));

            when(compteEpargneRepository.findByIdOptional(COMPTE_ID))
                    .thenReturn(Optional.of(compte));
            when(transactionEpargneRepository.find(anyString(), eq(COMPTE_ID)))
                    .thenReturn(mockEpargneQuery(List.of(txAfter, txIn)));

            byte[] result = service.genererReleveEpargne(COMPTE_ID, DATE_DEBUT, DATE_FIN);
            assertThat(result).isNotNull().isNotEmpty();
        }

        @Test
        @DisplayName("null dateDebut and dateFin — no filtering applied")
        void nullDates_noFiltering() {
            CompteEpargne compte = buildCompteEpargne(true, true);
            TransactionEpargne tx = buildTxEpargne(TypeTransactionEpargne.DEPOT,
                    new BigDecimal("5000"), LocalDateTime.now());

            when(compteEpargneRepository.findByIdOptional(COMPTE_ID))
                    .thenReturn(Optional.of(compte));
            when(transactionEpargneRepository.find(anyString(), eq(COMPTE_ID)))
                    .thenReturn(mockEpargneQuery(List.of(tx)));

            byte[] result = service.genererReleveEpargne(COMPTE_ID, null, null);
            assertThat(result).isNotNull().isNotEmpty();
        }

        @Test
        @DisplayName("only dateDebut set — only lower bound filtering")
        void onlyDateDebut_filterOnlyLowerBound() {
            CompteEpargne compte = buildCompteEpargne(true, true);
            TransactionEpargne tx = buildTxEpargne(TypeTransactionEpargne.DEPOT,
                    new BigDecimal("5000"),
                    LocalDateTime.of(2026, 2, 1, 10, 0));

            when(compteEpargneRepository.findByIdOptional(COMPTE_ID))
                    .thenReturn(Optional.of(compte));
            when(transactionEpargneRepository.find(anyString(), eq(COMPTE_ID)))
                    .thenReturn(mockEpargneQuery(List.of(tx)));

            byte[] result = service.genererReleveEpargne(COMPTE_ID, DATE_DEBUT, null);
            assertThat(result).isNotNull().isNotEmpty();
        }

        @Test
        @DisplayName("only dateFin set — only upper bound filtering")
        void onlyDateFin_filterOnlyUpperBound() {
            CompteEpargne compte = buildCompteEpargne(true, true);
            TransactionEpargne tx = buildTxEpargne(TypeTransactionEpargne.DEPOT,
                    new BigDecimal("5000"),
                    LocalDateTime.of(2026, 1, 5, 10, 0));

            when(compteEpargneRepository.findByIdOptional(COMPTE_ID))
                    .thenReturn(Optional.of(compte));
            when(transactionEpargneRepository.find(anyString(), eq(COMPTE_ID)))
                    .thenReturn(mockEpargneQuery(List.of(tx)));

            byte[] result = service.genererReleveEpargne(COMPTE_ID, null, DATE_FIN);
            assertThat(result).isNotNull().isNotEmpty();
        }

        @Test
        @DisplayName("non-empty txs — solde ouverture taken from first tx soldeAvant")
        void nonEmptyTxs_soldeOuvertureFromFirstTx() {
            CompteEpargne compte = buildCompteEpargne(true, true);
            TransactionEpargne tx = buildTxEpargne(TypeTransactionEpargne.DEPOT,
                    new BigDecimal("10000"),
                    LocalDateTime.of(2026, 1, 5, 10, 0));
            tx.setSoldeAvant(new BigDecimal("25000"));

            when(compteEpargneRepository.findByIdOptional(COMPTE_ID))
                    .thenReturn(Optional.of(compte));
            when(transactionEpargneRepository.find(anyString(), eq(COMPTE_ID)))
                    .thenReturn(mockEpargneQuery(List.of(tx)));

            byte[] result = service.genererReleveEpargne(COMPTE_ID, DATE_DEBUT, DATE_FIN);
            assertThat(result).isNotNull().isNotEmpty();
        }
    }

    // ─── genererReleveParts ────────────────────────────────────────────────────

    @Nested
    @DisplayName("genererReleveParts")
    class GenererRelevePartsTests {

        @Test
        @DisplayName("throws NotFoundException when compte not found")
        void compteNotFound_throws() {
            when(comptePartsSocialesRepository.findByIdOptional(COMPTE_ID))
                    .thenReturn(Optional.empty());

            assertThatThrownBy(() -> service.genererReleveParts(COMPTE_ID, DATE_DEBUT, DATE_FIN))
                    .isInstanceOf(NotFoundException.class)
                    .hasMessageContaining("Compte parts sociales introuvable");
        }

        @Test
        @DisplayName("no transactions — returns PDF with empty table")
        void noTransactions_returnsPdf() {
            ComptePartsSociales compte = buildComptePartsSociales(true, true);

            when(comptePartsSocialesRepository.findByIdOptional(COMPTE_ID))
                    .thenReturn(Optional.of(compte));
            when(transactionPartsSocialesRepository.findByCompte(COMPTE_ID))
                    .thenReturn(Collections.emptyList());

            byte[] result = service.genererReleveParts(COMPTE_ID, DATE_DEBUT, DATE_FIN);
            assertThat(result).isNotNull().isNotEmpty();
        }

        @Test
        @DisplayName("with transactions — generates PDF with rows")
        void withTransactions_generatesPdf() {
            ComptePartsSociales compte = buildComptePartsSociales(true, true);
            TransactionPartsSociales tx = buildTxParts(TypeTransactionPartsSociales.SOUSCRIPTION,
                    new BigDecimal("25000"), LocalDateTime.of(2026, 1, 10, 9, 0));

            when(comptePartsSocialesRepository.findByIdOptional(COMPTE_ID))
                    .thenReturn(Optional.of(compte));
            when(transactionPartsSocialesRepository.findByCompte(COMPTE_ID))
                    .thenReturn(List.of(tx));

            byte[] result = service.genererReleveParts(COMPTE_ID, DATE_DEBUT, DATE_FIN);
            assertThat(result).isNotNull().isNotEmpty();
        }

        @Test
        @DisplayName("null motif — uses typeTransaction.getLibelle()")
        void nullMotif_usesTypeLibelle() {
            ComptePartsSociales compte = buildComptePartsSociales(true, true);
            TransactionPartsSociales tx = buildTxParts(TypeTransactionPartsSociales.PAIEMENT_DIVIDENDE,
                    new BigDecimal("1000"), LocalDateTime.now());
            tx.setMotif(null);

            when(comptePartsSocialesRepository.findByIdOptional(COMPTE_ID))
                    .thenReturn(Optional.of(compte));
            when(transactionPartsSocialesRepository.findByCompte(COMPTE_ID))
                    .thenReturn(List.of(tx));

            byte[] result = service.genererReleveParts(COMPTE_ID, DATE_DEBUT, DATE_FIN);
            assertThat(result).isNotNull().isNotEmpty();
        }

        @Test
        @DisplayName("null organisation — uses 'UnionFlow' as fallback")
        void nullOrganisation_usesUnionFlowFallback() {
            ComptePartsSociales compte = buildComptePartsSociales(false, true); // no org

            when(comptePartsSocialesRepository.findByIdOptional(COMPTE_ID))
                    .thenReturn(Optional.of(compte));
            when(transactionPartsSocialesRepository.findByCompte(COMPTE_ID))
                    .thenReturn(Collections.emptyList());

            byte[] result = service.genererReleveParts(COMPTE_ID, DATE_DEBUT, DATE_FIN);
            assertThat(result).isNotNull().isNotEmpty();
        }

        @Test
        @DisplayName("null membre — empty titulaire string")
        void nullMembre_emptyTitulaire() {
            ComptePartsSociales compte = buildComptePartsSociales(true, false); // no membre

            when(comptePartsSocialesRepository.findByIdOptional(COMPTE_ID))
                    .thenReturn(Optional.of(compte));
            when(transactionPartsSocialesRepository.findByCompte(COMPTE_ID))
                    .thenReturn(Collections.emptyList());

            byte[] result = service.genererReleveParts(COMPTE_ID, DATE_DEBUT, DATE_FIN);
            assertThat(result).isNotNull().isNotEmpty();
        }

        @Test
        @DisplayName("dateDebut filter — transactions before dateDebut excluded")
        void dateDebutFilter_excludesOlderTx() {
            ComptePartsSociales compte = buildComptePartsSociales(true, true);

            TransactionPartsSociales txBefore = buildTxParts(TypeTransactionPartsSociales.SOUSCRIPTION,
                    new BigDecimal("5000"), LocalDateTime.of(2025, 12, 15, 10, 0));
            TransactionPartsSociales txIn = buildTxParts(TypeTransactionPartsSociales.SOUSCRIPTION,
                    new BigDecimal("5000"), LocalDateTime.of(2026, 1, 15, 10, 0));

            when(comptePartsSocialesRepository.findByIdOptional(COMPTE_ID))
                    .thenReturn(Optional.of(compte));
            when(transactionPartsSocialesRepository.findByCompte(COMPTE_ID))
                    .thenReturn(new ArrayList<>(List.of(txBefore, txIn)));

            byte[] result = service.genererReleveParts(COMPTE_ID, DATE_DEBUT, DATE_FIN);
            assertThat(result).isNotNull().isNotEmpty();
        }

        @Test
        @DisplayName("dateFin filter — transactions after dateFin excluded")
        void dateFinFilter_excludesNewerTx() {
            ComptePartsSociales compte = buildComptePartsSociales(true, true);

            TransactionPartsSociales txAfter = buildTxParts(TypeTransactionPartsSociales.SOUSCRIPTION,
                    new BigDecimal("5000"), LocalDateTime.of(2026, 4, 15, 10, 0));
            TransactionPartsSociales txIn = buildTxParts(TypeTransactionPartsSociales.SOUSCRIPTION,
                    new BigDecimal("5000"), LocalDateTime.of(2026, 2, 15, 10, 0));

            when(comptePartsSocialesRepository.findByIdOptional(COMPTE_ID))
                    .thenReturn(Optional.of(compte));
            when(transactionPartsSocialesRepository.findByCompte(COMPTE_ID))
                    .thenReturn(new ArrayList<>(List.of(txAfter, txIn)));

            byte[] result = service.genererReleveParts(COMPTE_ID, DATE_DEBUT, DATE_FIN);
            assertThat(result).isNotNull().isNotEmpty();
        }

        @Test
        @DisplayName("null dateDebut and dateFin — no filtering")
        void nullDates_noFiltering() {
            ComptePartsSociales compte = buildComptePartsSociales(true, true);
            TransactionPartsSociales tx = buildTxParts(TypeTransactionPartsSociales.CESSION_PARTIELLE,
                    new BigDecimal("10000"), LocalDateTime.now());

            when(comptePartsSocialesRepository.findByIdOptional(COMPTE_ID))
                    .thenReturn(Optional.of(compte));
            when(transactionPartsSocialesRepository.findByCompte(COMPTE_ID))
                    .thenReturn(new ArrayList<>(List.of(tx)));

            byte[] result = service.genererReleveParts(COMPTE_ID, null, null);
            assertThat(result).isNotNull().isNotEmpty();
        }

        @Test
        @DisplayName("only dateDebut set — lower bound filtering only")
        void onlyDateDebut_lowerBoundOnly() {
            ComptePartsSociales compte = buildComptePartsSociales(true, true);
            TransactionPartsSociales tx = buildTxParts(TypeTransactionPartsSociales.SOUSCRIPTION,
                    new BigDecimal("5000"), LocalDateTime.of(2026, 2, 1, 10, 0));

            when(comptePartsSocialesRepository.findByIdOptional(COMPTE_ID))
                    .thenReturn(Optional.of(compte));
            when(transactionPartsSocialesRepository.findByCompte(COMPTE_ID))
                    .thenReturn(new ArrayList<>(List.of(tx)));

            byte[] result = service.genererReleveParts(COMPTE_ID, DATE_DEBUT, null);
            assertThat(result).isNotNull().isNotEmpty();
        }

        @Test
        @DisplayName("only dateFin set — upper bound filtering only")
        void onlyDateFin_upperBoundOnly() {
            ComptePartsSociales compte = buildComptePartsSociales(true, true);
            TransactionPartsSociales tx = buildTxParts(TypeTransactionPartsSociales.SOUSCRIPTION,
                    new BigDecimal("5000"), LocalDateTime.of(2026, 1, 5, 10, 0));

            when(comptePartsSocialesRepository.findByIdOptional(COMPTE_ID))
                    .thenReturn(Optional.of(compte));
            when(transactionPartsSocialesRepository.findByCompte(COMPTE_ID))
                    .thenReturn(new ArrayList<>(List.of(tx)));

            byte[] result = service.genererReleveParts(COMPTE_ID, null, DATE_FIN);
            assertThat(result).isNotNull().isNotEmpty();
        }

        @Test
        @DisplayName("transactions from repository are reversed (DESC → ASC)")
        void transactionsReversed() {
            ComptePartsSociales compte = buildComptePartsSociales(true, true);

            // Repository returns DESC; service reverses to ASC
            TransactionPartsSociales tx1 = buildTxParts(TypeTransactionPartsSociales.SOUSCRIPTION,
                    new BigDecimal("5000"), LocalDateTime.of(2026, 3, 15, 10, 0)); // later
            TransactionPartsSociales tx2 = buildTxParts(TypeTransactionPartsSociales.SOUSCRIPTION,
                    new BigDecimal("5000"), LocalDateTime.of(2026, 1, 10, 10, 0)); // earlier

            List<TransactionPartsSociales> descList = new ArrayList<>(List.of(tx1, tx2));

            when(comptePartsSocialesRepository.findByIdOptional(COMPTE_ID))
                    .thenReturn(Optional.of(compte));
            when(transactionPartsSocialesRepository.findByCompte(COMPTE_ID))
                    .thenReturn(descList);

            byte[] result = service.genererReleveParts(COMPTE_ID, null, null);
            assertThat(result).isNotNull().isNotEmpty();
        }

        @Test
        @DisplayName("multiple transaction types — all rendered correctly")
        void multipleTransactionTypes_allRendered() {
            ComptePartsSociales compte = buildComptePartsSociales(true, true);

            List<TransactionPartsSociales> txs = new ArrayList<>();
            txs.add(buildTxParts(TypeTransactionPartsSociales.SOUSCRIPTION,
                    new BigDecimal("25000"), LocalDateTime.of(2026, 1, 5, 10, 0)));
            txs.add(buildTxParts(TypeTransactionPartsSociales.PAIEMENT_DIVIDENDE,
                    new BigDecimal("1000"), LocalDateTime.of(2026, 2, 1, 10, 0)));
            txs.add(buildTxParts(TypeTransactionPartsSociales.CESSION_PARTIELLE,
                    new BigDecimal("5000"), LocalDateTime.of(2026, 3, 1, 10, 0)));
            txs.add(buildTxParts(TypeTransactionPartsSociales.RACHAT_TOTAL,
                    new BigDecimal("25000"), LocalDateTime.of(2026, 3, 15, 10, 0)));
            txs.add(buildTxParts(TypeTransactionPartsSociales.CORRECTION,
                    new BigDecimal("500"), LocalDateTime.of(2026, 3, 20, 10, 0)));

            when(comptePartsSocialesRepository.findByIdOptional(COMPTE_ID))
                    .thenReturn(Optional.of(compte));
            when(transactionPartsSocialesRepository.findByCompte(COMPTE_ID))
                    .thenReturn(txs);

            byte[] result = service.genererReleveParts(COMPTE_ID, null, null);
            assertThat(result).isNotNull().isNotEmpty();
        }
    }

    // ─── formatPeriode — indirect coverage via public methods ─────────────────

    @Test
    @DisplayName("formatPeriode — debut only set — 'depuis le' prefix")
    void formatPeriode_debutOnly_returnsDebuPrefix() {
        CompteEpargne compte = buildCompteEpargne(true, true);
        when(compteEpargneRepository.findByIdOptional(COMPTE_ID))
                .thenReturn(Optional.of(compte));
        when(transactionEpargneRepository.find(anyString(), eq(COMPTE_ID)))
                .thenReturn(mockEpargneQuery(Collections.emptyList()));

        // dateDebut set, dateFin null → "depuis le ..."
        byte[] result = service.genererReleveEpargne(COMPTE_ID, DATE_DEBUT, null);
        assertThat(result).isNotNull().isNotEmpty();
    }

    @Test
    @DisplayName("formatPeriode — fin only set — 'jusqu au' prefix")
    void formatPeriode_finOnly_returnsFinPrefix() {
        CompteEpargne compte = buildCompteEpargne(true, true);
        when(compteEpargneRepository.findByIdOptional(COMPTE_ID))
                .thenReturn(Optional.of(compte));
        when(transactionEpargneRepository.find(anyString(), eq(COMPTE_ID)))
                .thenReturn(mockEpargneQuery(Collections.emptyList()));

        // dateDebut null, dateFin set → "jusqu'au ..."
        byte[] result = service.genererReleveEpargne(COMPTE_ID, null, DATE_FIN);
        assertThat(result).isNotNull().isNotEmpty();
    }

    @Test
    @DisplayName("formatPeriode — both null — 'Toutes operations'")
    void formatPeriode_bothNull_toutesOperations() {
        CompteEpargne compte = buildCompteEpargne(true, true);
        when(compteEpargneRepository.findByIdOptional(COMPTE_ID))
                .thenReturn(Optional.of(compte));
        when(transactionEpargneRepository.find(anyString(), eq(COMPTE_ID)))
                .thenReturn(mockEpargneQuery(Collections.emptyList()));

        byte[] result = service.genererReleveEpargne(COMPTE_ID, null, null);
        assertThat(result).isNotNull().isNotEmpty();
    }

    // ─── cellAmount null/zero branch ──────────────────────────────────────────

    @Test
    @DisplayName("cellAmount — null soldeApres renders empty cell")
    void cellAmount_nullSoldeApres_emptyCell() {
        CompteEpargne compte = buildCompteEpargne(true, true);
        TransactionEpargne tx = buildTxEpargne(TypeTransactionEpargne.DEPOT,
                new BigDecimal("1000"), LocalDateTime.now());
        tx.setSoldeApres(null); // null — cellAmount should handle gracefully

        when(compteEpargneRepository.findByIdOptional(COMPTE_ID))
                .thenReturn(Optional.of(compte));
        when(transactionEpargneRepository.find(anyString(), eq(COMPTE_ID)))
                .thenReturn(mockEpargneQuery(List.of(tx)));

        byte[] result = service.genererReleveEpargne(COMPTE_ID, null, null);
        assertThat(result).isNotNull().isNotEmpty();
    }
}
