package de.cannamanage.service;

import de.cannamanage.domain.constants.ComplianceConstants;
import de.cannamanage.domain.entity.Batch;
import de.cannamanage.domain.entity.Member;
import de.cannamanage.domain.entity.MonthlyQuota;
import de.cannamanage.domain.entity.Strain;
import de.cannamanage.domain.enums.BatchStatus;
import de.cannamanage.domain.enums.MemberStatus;
import de.cannamanage.service.exception.MemberNotFoundException;
import de.cannamanage.service.exception.QuotaExceededException;
import de.cannamanage.service.exception.QuotaViolationCode;
import de.cannamanage.service.repository.BatchRepository;
import de.cannamanage.service.repository.DistributionRepository;
import de.cannamanage.service.repository.MemberRepository;
import de.cannamanage.service.repository.MonthlyQuotaRepository;
import de.cannamanage.service.repository.StrainRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
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.Instant;
import java.time.LocalDate;
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.Mockito.when;

@ExtendWith(MockitoExtension.class)
class ComplianceServiceTest {

    @Mock MemberRepository memberRepository;
    @Mock DistributionRepository distributionRepository;
    @Mock BatchRepository batchRepository;
    @Mock MonthlyQuotaRepository monthlyQuotaRepository;
    @Mock StrainRepository strainRepository;

    @InjectMocks ComplianceService complianceService;

    private static final UUID ADULT_MEMBER_ID   = UUID.fromString("11111111-1111-1111-1111-111111111111");
    private static final UUID UNDER21_MEMBER_ID = UUID.fromString("22222222-2222-2222-2222-222222222222");
    private static final UUID BATCH_ID          = UUID.fromString("33333333-3333-3333-3333-333333333333");
    private static final UUID STRAIN_ID         = UUID.fromString("44444444-4444-4444-4444-444444444444");
    private static final UUID HIGH_THC_STRAIN_ID = UUID.fromString("55555555-5555-5555-5555-555555555555");

    private Member adultMember;
    private Member under21Member;
    private Batch availableBatch;
    private Strain normalStrain;
    private Strain highThcStrain;

    @BeforeEach
    void setUp() {
        adultMember = new Member();
        adultMember.setStatus(MemberStatus.ACTIVE);
        adultMember.setUnder21(false);

        under21Member = new Member();
        under21Member.setStatus(MemberStatus.ACTIVE);
        under21Member.setUnder21(true);

        normalStrain = new Strain();
        normalStrain.setThcPercentage(new BigDecimal("8.0"));
        normalStrain.setCbdPercentage(new BigDecimal("2.0"));

        highThcStrain = new Strain();
        highThcStrain.setThcPercentage(new BigDecimal("22.0"));
        highThcStrain.setCbdPercentage(BigDecimal.ZERO);

        availableBatch = new Batch();
        availableBatch.setStatus(BatchStatus.AVAILABLE);
        availableBatch.setStrainId(STRAIN_ID);
    }

    // TC-001: Adult at monthly limit → QUOTA_EXCEEDED_MONTHLY
    @Test
    @DisplayName("TC-001: Adult at 50g monthly usage → QUOTA_EXCEEDED_MONTHLY")
    void tc001_adultAtMonthlyLimit() {
        when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(adultMember));
        when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(availableBatch));
        when(strainRepository.findById(STRAIN_ID)).thenReturn(Optional.of(normalStrain));
        when(distributionRepository.sumQuantityByMemberAndDay(any(), any(Instant.class), any(Instant.class)))
                .thenReturn(BigDecimal.ZERO);
        MonthlyQuota quota = buildQuota("50.0", ComplianceConstants.ADULT_MONTHLY_LIMIT_GRAMS);
        when(monthlyQuotaRepository.findByMemberIdAndYearAndMonth(any(), anyInt(), anyInt()))
                .thenReturn(Optional.of(quota));

        assertThatThrownBy(() ->
                complianceService.checkDistributionAllowed(ADULT_MEMBER_ID, BATCH_ID, new BigDecimal("1.0")))
                .isInstanceOf(QuotaExceededException.class)
                .extracting("code").isEqualTo(QuotaViolationCode.QUOTA_EXCEEDED_MONTHLY);
    }

    // TC-002: Under-21 at monthly limit → QUOTA_EXCEEDED_MONTHLY
    @Test
    @DisplayName("TC-002: Under-21 at 30g monthly usage → QUOTA_EXCEEDED_MONTHLY")
    void tc002_under21AtMonthlyLimit() {
        when(memberRepository.findById(UNDER21_MEMBER_ID)).thenReturn(Optional.of(under21Member));
        when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(availableBatch));
        when(strainRepository.findById(STRAIN_ID)).thenReturn(Optional.of(normalStrain));
        when(distributionRepository.sumQuantityByMemberAndDay(any(), any(Instant.class), any(Instant.class)))
                .thenReturn(BigDecimal.ZERO);
        MonthlyQuota quota = buildQuota("30.0", ComplianceConstants.UNDER21_MONTHLY_LIMIT_GRAMS);
        when(monthlyQuotaRepository.findByMemberIdAndYearAndMonth(any(), anyInt(), anyInt()))
                .thenReturn(Optional.of(quota));

        assertThatThrownBy(() ->
                complianceService.checkDistributionAllowed(UNDER21_MEMBER_ID, BATCH_ID, new BigDecimal("1.0")))
                .isInstanceOf(QuotaExceededException.class)
                .extracting("code").isEqualTo(QuotaViolationCode.QUOTA_EXCEEDED_MONTHLY);
    }

    // TC-003: Adult at daily limit → QUOTA_EXCEEDED_DAILY
    @Test
    @DisplayName("TC-003: Adult at 25g today requesting 1g → QUOTA_EXCEEDED_DAILY")
    void tc003_adultAtDailyLimit() {
        when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(adultMember));
        when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(availableBatch));
        when(strainRepository.findById(STRAIN_ID)).thenReturn(Optional.of(normalStrain));
        when(distributionRepository.sumQuantityByMemberAndDay(any(), any(Instant.class), any(Instant.class)))
                .thenReturn(new BigDecimal("25.0"));

        assertThatThrownBy(() ->
                complianceService.checkDistributionAllowed(ADULT_MEMBER_ID, BATCH_ID, new BigDecimal("1.0")))
                .isInstanceOf(QuotaExceededException.class)
                .extracting("code").isEqualTo(QuotaViolationCode.QUOTA_EXCEEDED_DAILY);
    }

    // TC-004: Under-21 + high THC → HIGH_THC_RESTRICTED_UNDER_21
    @Test
    @DisplayName("TC-004: Under-21 + 22% THC batch → HIGH_THC_RESTRICTED_UNDER_21")
    void tc004_under21HighThcStrain() {
        Batch highThcBatch = new Batch();
        highThcBatch.setStatus(BatchStatus.AVAILABLE);
        highThcBatch.setStrainId(HIGH_THC_STRAIN_ID);

        when(memberRepository.findById(UNDER21_MEMBER_ID)).thenReturn(Optional.of(under21Member));
        when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(highThcBatch));
        when(strainRepository.findById(HIGH_THC_STRAIN_ID)).thenReturn(Optional.of(highThcStrain));

        assertThatThrownBy(() ->
                complianceService.checkDistributionAllowed(UNDER21_MEMBER_ID, BATCH_ID, new BigDecimal("5.0")))
                .isInstanceOf(QuotaExceededException.class)
                .extracting("code").isEqualTo(QuotaViolationCode.HIGH_THC_RESTRICTED_UNDER_21);
    }

    // TC-005: Adult at 49g requesting 2g → QUOTA_EXCEEDED_MONTHLY (boundary)
    @Test
    @DisplayName("TC-005: Adult at 49g monthly requesting 2g → QUOTA_EXCEEDED_MONTHLY (boundary)")
    void tc005_adultAt49gRequesting2g() {
        when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(adultMember));
        when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(availableBatch));
        when(strainRepository.findById(STRAIN_ID)).thenReturn(Optional.of(normalStrain));
        when(distributionRepository.sumQuantityByMemberAndDay(any(), any(Instant.class), any(Instant.class)))
                .thenReturn(BigDecimal.ZERO);
        MonthlyQuota quota = buildQuota("49.0", ComplianceConstants.ADULT_MONTHLY_LIMIT_GRAMS);
        when(monthlyQuotaRepository.findByMemberIdAndYearAndMonth(any(), anyInt(), anyInt()))
                .thenReturn(Optional.of(quota));

        assertThatThrownBy(() ->
                complianceService.checkDistributionAllowed(ADULT_MEMBER_ID, BATCH_ID, new BigDecimal("2.0")))
                .isInstanceOf(QuotaExceededException.class)
                .extracting("code").isEqualTo(QuotaViolationCode.QUOTA_EXCEEDED_MONTHLY);
    }

    // TC-006: Adult at 0g requesting 25g → allowed, remainingDaily=0
    @Test
    @DisplayName("TC-006: Adult at 0g requesting 25g → allowed, remainingDaily=0")
    void tc006_adultAt0gRequesting25g_allowed() {
        when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(adultMember));
        when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(availableBatch));
        when(strainRepository.findById(STRAIN_ID)).thenReturn(Optional.of(normalStrain));
        when(distributionRepository.sumQuantityByMemberAndDay(any(), any(Instant.class), any(Instant.class)))
                .thenReturn(BigDecimal.ZERO);
        MonthlyQuota quota = buildQuota("0.0", ComplianceConstants.ADULT_MONTHLY_LIMIT_GRAMS);
        when(monthlyQuotaRepository.findByMemberIdAndYearAndMonth(any(), anyInt(), anyInt()))
                .thenReturn(Optional.of(quota));

        var result = complianceService.checkDistributionAllowed(ADULT_MEMBER_ID, BATCH_ID, new BigDecimal("25.0"));

        assertThat(result.allowed()).isTrue();
        assertThat(result.remainingDaily()).isEqualByComparingTo(BigDecimal.ZERO);
        assertThat(result.remainingMonthly()).isEqualByComparingTo(new BigDecimal("25.0"));
        assertThat(result.isUnder21()).isFalse();
    }

    // TC-007: Adult at 24.9g today requesting 0.1g → allowed, remainingDaily=0
    @Test
    @DisplayName("TC-007: Adult at 24.9g today requesting 0.1g → allowed, remainingDaily=0")
    void tc007_adultAt24dot9gRequesting0dot1g_allowed() {
        when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(adultMember));
        when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(availableBatch));
        when(strainRepository.findById(STRAIN_ID)).thenReturn(Optional.of(normalStrain));
        when(distributionRepository.sumQuantityByMemberAndDay(any(), any(Instant.class), any(Instant.class)))
                .thenReturn(new BigDecimal("24.9"));
        MonthlyQuota quota = buildQuota("24.9", ComplianceConstants.ADULT_MONTHLY_LIMIT_GRAMS);
        when(monthlyQuotaRepository.findByMemberIdAndYearAndMonth(any(), anyInt(), anyInt()))
                .thenReturn(Optional.of(quota));

        var result = complianceService.checkDistributionAllowed(ADULT_MEMBER_ID, BATCH_ID, new BigDecimal("0.1"));

        assertThat(result.allowed()).isTrue();
        assertThat(result.remainingDaily()).isEqualByComparingTo(BigDecimal.ZERO);
    }

    // TC-008: Adult at 24.9g today requesting 0.2g → QUOTA_EXCEEDED_DAILY (boundary)
    @Test
    @DisplayName("TC-008: Adult at 24.9g today requesting 0.2g → QUOTA_EXCEEDED_DAILY (boundary)")
    void tc008_adultAt24dot9gRequesting0dot2g_dailyExceeded() {
        when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(adultMember));
        when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(availableBatch));
        when(strainRepository.findById(STRAIN_ID)).thenReturn(Optional.of(normalStrain));
        when(distributionRepository.sumQuantityByMemberAndDay(any(), any(Instant.class), any(Instant.class)))
                .thenReturn(new BigDecimal("24.9"));

        assertThatThrownBy(() ->
                complianceService.checkDistributionAllowed(ADULT_MEMBER_ID, BATCH_ID, new BigDecimal("0.2")))
                .isInstanceOf(QuotaExceededException.class)
                .extracting("code").isEqualTo(QuotaViolationCode.QUOTA_EXCEEDED_DAILY);
    }

    // TC-009: SUSPENDED member → MEMBER_INACTIVE
    @Test
    @DisplayName("TC-009: SUSPENDED member → MEMBER_INACTIVE")
    void tc009_suspendedMember() {
        Member suspended = new Member();
        suspended.setStatus(MemberStatus.SUSPENDED);
        when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(suspended));

        assertThatThrownBy(() ->
                complianceService.checkDistributionAllowed(ADULT_MEMBER_ID, BATCH_ID, new BigDecimal("5.0")))
                .isInstanceOf(QuotaExceededException.class)
                .extracting("code").isEqualTo(QuotaViolationCode.MEMBER_INACTIVE);
    }

    // TC-010: EXPELLED member → MEMBER_INACTIVE
    @Test
    @DisplayName("TC-010: EXPELLED member → MEMBER_INACTIVE")
    void tc010_expelledMember() {
        Member expelled = new Member();
        expelled.setStatus(MemberStatus.EXPELLED);
        when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(expelled));

        assertThatThrownBy(() ->
                complianceService.checkDistributionAllowed(ADULT_MEMBER_ID, BATCH_ID, new BigDecimal("5.0")))
                .isInstanceOf(QuotaExceededException.class)
                .extracting("code").isEqualTo(QuotaViolationCode.MEMBER_INACTIVE);
    }

    // TC-011: No existing quota → creates new quota (createNewQuota path)
    @Test
    @DisplayName("TC-011: No existing quota → creates new quota, distribution allowed")
    void tc011_noExistingQuota_createsNewAndAllows() {
        when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(adultMember));
        when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(availableBatch));
        when(strainRepository.findById(STRAIN_ID)).thenReturn(Optional.of(normalStrain));
        when(distributionRepository.sumQuantityByMemberAndDay(any(), any(Instant.class), any(Instant.class)))
                .thenReturn(BigDecimal.ZERO);
        // Return empty — triggers createNewQuota
        when(monthlyQuotaRepository.findByMemberIdAndYearAndMonth(any(), anyInt(), anyInt()))
                .thenReturn(Optional.empty());
        MonthlyQuota newQuota = buildQuota("0.0", ComplianceConstants.ADULT_MONTHLY_LIMIT_GRAMS);
        when(monthlyQuotaRepository.save(any())).thenReturn(newQuota);

        var result = complianceService.checkDistributionAllowed(ADULT_MEMBER_ID, BATCH_ID, new BigDecimal("10.0"));

        assertThat(result.allowed()).isTrue();
        assertThat(result.isUnder21()).isFalse();
    }

    // TC-012: Under-21 no existing quota → creates quota with under-21 limit
    @Test
    @DisplayName("TC-012: Under-21 no existing quota → creates quota with 30g limit")
    void tc012_under21NoExistingQuota_createsNewWithUnder21Limit() {
        when(memberRepository.findById(UNDER21_MEMBER_ID)).thenReturn(Optional.of(under21Member));
        when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(availableBatch));
        when(strainRepository.findById(STRAIN_ID)).thenReturn(Optional.of(normalStrain));
        when(distributionRepository.sumQuantityByMemberAndDay(any(), any(Instant.class), any(Instant.class)))
                .thenReturn(BigDecimal.ZERO);
        when(monthlyQuotaRepository.findByMemberIdAndYearAndMonth(any(), anyInt(), anyInt()))
                .thenReturn(Optional.empty());
        MonthlyQuota newQuota = buildQuota("0.0", ComplianceConstants.UNDER21_MONTHLY_LIMIT_GRAMS);
        when(monthlyQuotaRepository.save(any())).thenReturn(newQuota);

        var result = complianceService.checkDistributionAllowed(UNDER21_MEMBER_ID, BATCH_ID, new BigDecimal("5.0"));

        assertThat(result.allowed()).isTrue();
        assertThat(result.isUnder21()).isTrue();
    }

    // TC-013: BATCH_UNAVAILABLE — batch is RECALLED
    @Test
    @DisplayName("TC-013: RECALLED batch → BATCH_UNAVAILABLE")
    void tc013_recalledBatch() {
        Batch recalled = new Batch();
        recalled.setStatus(BatchStatus.RECALLED);
        recalled.setStrainId(STRAIN_ID);

        when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(adultMember));
        when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(recalled));

        assertThatThrownBy(() ->
                complianceService.checkDistributionAllowed(ADULT_MEMBER_ID, BATCH_ID, new BigDecimal("5.0")))
                .isInstanceOf(QuotaExceededException.class)
                .extracting("code").isEqualTo(QuotaViolationCode.BATCH_UNAVAILABLE);
    }

    // TC-014: getQuotaStatus — adult member with existing quota
    @Test
    @DisplayName("TC-014: getQuotaStatus — adult with existing quota returns correct status")
    void tc014_getQuotaStatus_adultWithExistingQuota() {
        when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(adultMember));
        MonthlyQuota quota = buildQuota("20.0", ComplianceConstants.ADULT_MONTHLY_LIMIT_GRAMS);
        when(monthlyQuotaRepository.findByMemberIdAndYearAndMonth(any(), anyInt(), anyInt()))
                .thenReturn(Optional.of(quota));

        var status = complianceService.getQuotaStatus(ADULT_MEMBER_ID);

        assertThat(status.totalAllowed()).isEqualByComparingTo(ComplianceConstants.ADULT_MONTHLY_LIMIT_GRAMS);
        assertThat(status.totalUsed()).isEqualByComparingTo(new BigDecimal("20.0"));
        assertThat(status.remaining()).isEqualByComparingTo(new BigDecimal("30.0"));
        assertThat(status.isUnder21()).isFalse();
    }

    // TC-015: getQuotaStatus — under-21 with no existing quota
    @Test
    @DisplayName("TC-015: getQuotaStatus — under-21 with no quota returns 30g limit")
    void tc015_getQuotaStatus_under21NoExistingQuota() {
        when(memberRepository.findById(UNDER21_MEMBER_ID)).thenReturn(Optional.of(under21Member));
        when(monthlyQuotaRepository.findByMemberIdAndYearAndMonth(any(), anyInt(), anyInt()))
                .thenReturn(Optional.empty());

        var status = complianceService.getQuotaStatus(UNDER21_MEMBER_ID);

        assertThat(status.totalAllowed()).isEqualByComparingTo(ComplianceConstants.UNDER21_MONTHLY_LIMIT_GRAMS);
        assertThat(status.totalUsed()).isEqualByComparingTo(BigDecimal.ZERO);
        assertThat(status.isUnder21()).isTrue();
    }

    // TC-016: getQuotaStatus — member not found throws exception
    @Test
    @DisplayName("TC-016: getQuotaStatus — unknown member ID throws MemberNotFoundException")
    void tc016_getQuotaStatus_memberNotFound() {
        UUID unknown = UUID.fromString("99999999-9999-9999-9999-999999999999");
        when(memberRepository.findById(unknown)).thenReturn(Optional.empty());

        assertThatThrownBy(() -> complianceService.getQuotaStatus(unknown))
                .isInstanceOf(MemberNotFoundException.class);
    }

    // TC-017: validateMembershipAge — 18-year-old is allowed
    @Test
    @DisplayName("TC-017: validateMembershipAge — 18-year-old is allowed")
    void tc017_validateMembershipAge_18YearsOld_allowed() {
        // Just before birthday this year to keep age = 18
        LocalDate dob = LocalDate.now().minusYears(18).minusDays(1);
        // Should not throw
        complianceService.validateMembershipAge(dob);
    }

    // TC-018: validateMembershipAge — 17-year-old is rejected
    @Test
    @DisplayName("TC-018: validateMembershipAge — 17-year-old is rejected")
    void tc018_validateMembershipAge_17YearsOld_rejected() {
        LocalDate dob = LocalDate.now().minusYears(17);

        assertThatThrownBy(() -> complianceService.validateMembershipAge(dob))
                .isInstanceOf(QuotaExceededException.class)
                .extracting("code").isEqualTo(QuotaViolationCode.MEMBER_INACTIVE);
    }

    // TC-019: isUnder21 — 20-year-old returns true
    @Test
    @DisplayName("TC-019: isUnder21 — 20-year-old returns true")
    void tc019_isUnder21_20YearsOld_returnsTrue() {
        LocalDate dob = LocalDate.now().minusYears(20).minusDays(1);
        assertThat(complianceService.isUnder21(dob)).isTrue();
    }

    // TC-020: isUnder21 — 21-year-old returns false
    @Test
    @DisplayName("TC-020: isUnder21 — 21-year-old returns false")
    void tc020_isUnder21_21YearsOld_returnsFalse() {
        LocalDate dob = LocalDate.now().minusYears(21).minusDays(1);
        assertThat(complianceService.isUnder21(dob)).isFalse();
    }

    // TC-021: member not found in checkDistributionAllowed → MemberNotFoundException
    @Test
    @DisplayName("TC-021: Unknown member ID in checkDistributionAllowed → MemberNotFoundException")
    void tc021_memberNotFoundInCheck() {
        UUID unknown = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
        when(memberRepository.findById(unknown)).thenReturn(Optional.empty());

        assertThatThrownBy(() ->
                complianceService.checkDistributionAllowed(unknown, BATCH_ID, new BigDecimal("5.0")))
                .isInstanceOf(de.cannamanage.service.exception.MemberNotFoundException.class);
    }

    // TC-022: batch not found in checkDistributionAllowed → BatchNotFoundException
    @Test
    @DisplayName("TC-022: Unknown batch ID in checkDistributionAllowed → BatchNotFoundException")
    void tc022_batchNotFoundInCheck() {
        UUID unknownBatch = UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
        when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(adultMember));
        when(batchRepository.findById(unknownBatch)).thenReturn(Optional.empty());

        assertThatThrownBy(() ->
                complianceService.checkDistributionAllowed(ADULT_MEMBER_ID, unknownBatch, new BigDecimal("5.0")))
                .isInstanceOf(de.cannamanage.service.exception.BatchNotFoundException.class);
    }

    // TC-023: strain not found for batch → BatchNotFoundException
    @Test
    @DisplayName("TC-023: Strain not found for batch → BatchNotFoundException")
    void tc023_strainNotFoundForBatch() {
        UUID unknownStrain = UUID.fromString("cccccccc-cccc-cccc-cccc-cccccccccccc");
        Batch batchWithUnknownStrain = new Batch();
        batchWithUnknownStrain.setStatus(BatchStatus.AVAILABLE);
        batchWithUnknownStrain.setStrainId(unknownStrain);

        when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(adultMember));
        when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(batchWithUnknownStrain));
        when(strainRepository.findById(unknownStrain)).thenReturn(Optional.empty());

        assertThatThrownBy(() ->
                complianceService.checkDistributionAllowed(ADULT_MEMBER_ID, BATCH_ID, new BigDecimal("5.0")))
                .isInstanceOf(de.cannamanage.service.exception.BatchNotFoundException.class);
    }

    // TC-024: validateMembershipAge — birthday not yet occurred this year (age-- branch)
    @Test
    @DisplayName("TC-024: validateMembershipAge — birthday later this year → age is 17, rejected")
    void tc024_validateMembershipAge_birthdayLaterThisYear() {
        // Person who will turn 18 tomorrow — today they are 17 → should throw
        LocalDate dob = LocalDate.now().plusDays(1).minusYears(18);
        assertThatThrownBy(() -> complianceService.validateMembershipAge(dob))
                .isInstanceOf(QuotaExceededException.class)
                .extracting("code").isEqualTo(QuotaViolationCode.MEMBER_INACTIVE);
    }

    // TC-025: isUnder21 — birthday not yet occurred this year → age-- branch
    @Test
    @DisplayName("TC-025: isUnder21 — person turns 21 tomorrow → still under 21 today")
    void tc025_isUnder21_birthdayTomorrow_stillUnder21() {
        // Person who will turn 21 tomorrow — today they are 20 → still under 21
        LocalDate dob = LocalDate.now().plusDays(1).minusYears(21);
        assertThat(complianceService.isUnder21(dob)).isTrue();
    }

    // Helper
    private MonthlyQuota buildQuota(String totalDistributed, BigDecimal maxAllowed) {
        MonthlyQuota quota = new MonthlyQuota();
        quota.setTotalDistributed(new BigDecimal(totalDistributed));
        quota.setMaxAllowed(maxAllowed);
        return quota;
    }
}
