DonationService.java

package org.petify.funding.service;

import org.petify.funding.client.AchievementClient;
import org.petify.funding.client.ShelterClient;
import org.petify.funding.dto.DonationIntentRequest;
import org.petify.funding.dto.DonationRequest;
import org.petify.funding.dto.DonationResponse;
import org.petify.funding.dto.DonationStatistics;
import org.petify.funding.exception.ResourceNotFoundException;
import org.petify.funding.model.Donation;
import org.petify.funding.model.DonationStatus;
import org.petify.funding.model.DonationType;
import org.petify.funding.model.Fundraiser;
import org.petify.funding.model.Payment;
import org.petify.funding.model.PaymentStatus;
import org.petify.funding.repository.DonationRepository;
import org.petify.funding.repository.FundraiserRepository;
import org.petify.funding.repository.PaymentRepository;

import feign.FeignException;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.List;

@RequiredArgsConstructor
@Service
@Slf4j
@Getter
@Setter
public class DonationService {

    private final DonationRepository donationRepository;
    private final PaymentRepository paymentRepository;
    private final FundraiserRepository fundraiserRepository;
    private final ShelterClient shelterClient;
    private final AchievementClient achievementClient;

    @Transactional
    public DonationResponse createDraft(DonationIntentRequest request, Jwt jwt) {
        log.info("Creating draft donation for shelter {} by user {}", request.getShelterId(), jwt.getSubject());

        DonationRequest donationRequest = convertToDonationRequest(request);
        enrichDonorInformation(donationRequest, jwt);
        validateDonationRequest(donationRequest);
        validateShelterExists(donationRequest.getShelterId());

        if (donationRequest.getPetId() != null) {
            validatePetExists(donationRequest.getShelterId(), donationRequest.getPetId());
        }

        if (donationRequest.getFundraiserId() != null) {
            validateFundraiserExists(donationRequest.getFundraiserId(), donationRequest.getShelterId());
        }

        Donation donation = donationRequest.toEntity();
        donation.setStatus(DonationStatus.PENDING);
        Donation saved = donationRepository.save(donation);

        trackDonationAchievement(saved);

        log.info("Draft donation created successfully with ID: {}", saved.getId());
        return DonationResponse.fromEntity(saved);
    }

    @Transactional(readOnly = true)
    public Page<DonationResponse> getAll(Pageable pageable, DonationType type) {
        Page<Donation> page = (type == null)
                ? donationRepository.findAll(pageable)
                : donationRepository.findAllByDonationType(type, pageable);
        return page.map(DonationResponse::fromEntity);
    }

    @Transactional(readOnly = true)
    public DonationResponse get(Long id) {
        Donation donation = donationRepository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("Donation not found: " + id));
        return DonationResponse.fromEntity(donation);
    }

    @Transactional(readOnly = true)
    public Page<DonationResponse> getForShelter(Long shelterId, Pageable pageable) {
        return donationRepository.findByShelterId(shelterId, pageable)
                .map(DonationResponse::fromEntity);
    }

    @Transactional(readOnly = true)
    public Page<DonationResponse> getForPet(Long petId, Pageable pageable) {
        return donationRepository.findByPetId(petId, pageable)
                .map(DonationResponse::fromEntity);
    }

    @Transactional(readOnly = true)
    public Page<DonationResponse> getForFundraiser(Long fundraiserId, Pageable pageable) {
        return donationRepository.findByFundraiserId(fundraiserId, pageable)
                .map(DonationResponse::fromEntity);
    }

    @Transactional(readOnly = true)
    public Page<DonationResponse> getUserDonations(Pageable pageable) {
        String username = getCurrentUsername();
        if (username == null) {
            throw new RuntimeException("User not authenticated");
        }
        return donationRepository.findByDonorUsernameOrderByCreatedAtDesc(username, pageable)
                .map(DonationResponse::fromEntity);
    }

    @Transactional
    public void delete(Long id) {
        try {
            Donation donation = donationRepository.findById(id)
                    .orElseThrow(() -> new ResourceNotFoundException("Donation not found: " + id));

            if (donation.hasPendingPayments()) {
                throw new RuntimeException("Cannot delete donation with pending payments");
            }

            donationRepository.deleteById(id);
            log.info("Donation {} deleted successfully", id);

        } catch (EmptyResultDataAccessException ex) {
            throw new ResourceNotFoundException("Donation with id: " + id + " not found");
        }
    }

    @Transactional
    public DonationResponse cancelDonation(Long donationId) {
        String currentUsername = getCurrentUsername();

        Donation donation = donationRepository.findById(donationId)
                .orElseThrow(() -> new ResourceNotFoundException("Donation not found: " + donationId));

        if (!donation.getDonorUsername().equals(currentUsername)) {
            throw new RuntimeException("Can only cancel your own donations");
        }

        if (!donation.canBeCancelled()) {
            throw new RuntimeException("Donation cannot be cancelled in current state: " + donation.getStatus());
        }

        List<Payment> activePayments = donation.getPayments().stream()
                .filter(p -> p.getStatus() == PaymentStatus.PENDING || p.getStatus() == PaymentStatus.PROCESSING)
                .toList();

        for (Payment payment : activePayments) {
            try {
                payment.setStatus(PaymentStatus.CANCELLED);
                paymentRepository.save(payment);
                log.info("Cancelled payment {} for donation {}", payment.getId(), donationId);
            } catch (Exception e) {
                log.warn("Failed to cancel payment {} for donation {}: {}",
                        payment.getId(), donationId, e.getMessage());
            }
        }

        donation.setStatus(DonationStatus.CANCELLED);
        Donation saved = donationRepository.save(donation);

        log.info("Donation {} cancelled by user {}", donationId, currentUsername);
        return DonationResponse.fromEntity(saved);
    }

    @Transactional
    public DonationResponse refundDonation(Long donationId, BigDecimal amount) {
        Donation donation = donationRepository.findById(donationId)
                .orElseThrow(() -> new ResourceNotFoundException("Donation not found: " + donationId));

        if (!donation.canBeRefunded()) {
            throw new RuntimeException("Donation cannot be refunded in current state: " + donation.getStatus());
        }

        List<Payment> successfulPayments = donation.getPayments().stream()
                .filter(p -> p.getStatus() == PaymentStatus.SUCCEEDED)
                .toList();

        if (successfulPayments.isEmpty()) {
            throw new RuntimeException("No successful payments found for refund");
        }

        BigDecimal totalRefundAmount = BigDecimal.ZERO;
        BigDecimal refundAmountLeft = amount != null ? amount : donation.getTotalPaidAmount();

        for (Payment payment : successfulPayments) {
            if (refundAmountLeft.compareTo(BigDecimal.ZERO) <= 0) {
                break;
            }

            BigDecimal paymentRefundAmount = refundAmountLeft.min(payment.getAmount());

            try {
                PaymentStatus newStatus = paymentRefundAmount.compareTo(payment.getAmount()) == 0
                        ? PaymentStatus.REFUNDED
                        : PaymentStatus.PARTIALLY_REFUNDED;

                payment.setStatus(newStatus);
                paymentRepository.save(payment);

                totalRefundAmount = totalRefundAmount.add(paymentRefundAmount);
                refundAmountLeft = refundAmountLeft.subtract(paymentRefundAmount);

                log.info("Refunded {} from payment {} for donation {}",
                        paymentRefundAmount, payment.getId(), donationId);
            } catch (Exception e) {
                log.error("Failed to refund payment {} for donation {}: {}",
                        payment.getId(), donationId, e.getMessage());
            }
        }

        if (totalRefundAmount.compareTo(donation.getTotalPaidAmount()) >= 0) {
            donation.setStatus(DonationStatus.REFUNDED);
        }

        Donation saved = donationRepository.save(donation);
        log.info("Donation {} refunded (total amount: {})", donationId, totalRefundAmount);

        return DonationResponse.fromEntity(saved);
    }

    @Transactional(readOnly = true)
    public DonationStatistics getShelterDonationStats(Long shelterId) {
        Instant lastDonationInstant = donationRepository.getLastDonationDateByShelterId(shelterId);
        LocalDate lastDonationDate = null;

        if (lastDonationInstant != null) {
            lastDonationDate = lastDonationInstant.atZone(ZoneId.systemDefault()).toLocalDate();
        }

        return DonationStatistics.builder()
                .shelterId(shelterId)
                .totalDonations(donationRepository.countByShelterId(shelterId))
                .totalAmount(donationRepository.sumAmountByShelterId(shelterId))
                .completedDonations(donationRepository.countCompletedByShelterId(shelterId))
                .pendingDonations(donationRepository.countPendingByShelterId(shelterId))
                .averageDonationAmount(donationRepository.averageAmountByShelterId(shelterId))
                .lastDonationDate(lastDonationDate)
                .build();
    }

    private void enrichDonorInformation(DonationRequest request, Jwt jwt) {
        if (jwt != null) {
            if (request.getDonorUsername() == null && !Boolean.TRUE.equals(request.getAnonymous())) {
                request.setDonorUsername(jwt.getSubject());
            } else if (Boolean.TRUE.equals(request.getAnonymous())) {
                request.setDonorUsername(null);
            }
        }
    }

    private void validateDonationRequest(DonationRequest request) {
        if (!Boolean.TRUE.equals(request.getAnonymous())
                && (request.getDonorUsername() == null || request.getDonorUsername().trim().isEmpty())) {
            throw new RuntimeException("Donor username is required for non-anonymous donations");
        }

        if (request.getDonationType() == DonationType.MONEY) {
            if (request.getAmount() == null || request.getAmount().compareTo(BigDecimal.ZERO) <= 0) {
                throw new RuntimeException("Amount must be positive for monetary donations");
            }
            if (request.getItemName() != null || request.getUnitPrice() != null || request.getQuantity() != null) {
                throw new RuntimeException("Material donation fields should not be set for monetary donations");
            }
        }

        if (request.getDonationType() == DonationType.MATERIAL) {
            if (request.getItemName() == null || request.getItemName().trim().isEmpty()) {
                throw new RuntimeException("Item name is required for material donations");
            }
            if (request.getUnitPrice() == null || request.getUnitPrice().compareTo(BigDecimal.ZERO) <= 0) {
                throw new RuntimeException("Unit price must be positive for material donations");
            }
            if (request.getQuantity() == null || request.getQuantity() <= 0) {
                throw new RuntimeException("Quantity must be positive for material donations");
            }
        }
    }

    private void validateShelterExists(Long shelterId) {
        try {
            log.debug("Validating shelter ID: {}", shelterId);
            shelterClient.validateShelter(shelterId);
            log.debug("Shelter {} is valid and active", shelterId);
        } catch (FeignException.NotFound ex) {
            log.warn("Shelter {} not found", shelterId);
            throw new ResourceNotFoundException("Shelter not found: " + shelterId);
        } catch (FeignException.Forbidden ex) {
            log.warn("Shelter {} is not active", shelterId);
            throw new RuntimeException("Shelter is not active and cannot accept donations: " + shelterId);
        } catch (Exception ex) {
            log.error("Error validating shelter {}: {}", shelterId, ex.getMessage(), ex);
            throw new RuntimeException("Could not verify shelter existence: " + ex.getMessage());
        }
    }

    private void validatePetExists(Long shelterId, Long petId) {
        try {
            log.debug("Validating pet {} in shelter {}", petId, shelterId);
            shelterClient.validatePetInShelter(shelterId, petId);
            log.debug("Pet {} in shelter {} is valid for donations", petId, shelterId);
        } catch (FeignException.NotFound ex) {
            log.warn("Pet {} not found in shelter {} or doesn't belong to this shelter", petId, shelterId);
            throw new ResourceNotFoundException("Pet not found in shelter: pet=" + petId + ", shelter=" + shelterId);
        } catch (FeignException.Forbidden ex) {
            log.warn("Shelter {} is not active", shelterId);
            throw new RuntimeException("Shelter is not active: " + shelterId);
        } catch (FeignException.Gone ex) {
            log.warn("Pet {} is archived", petId);
            throw new RuntimeException("Pet is archived and not available for donations: " + petId);
        } catch (Exception ex) {
            log.error("Error validating pet {} in shelter {}: {}", petId, shelterId, ex.getMessage(), ex);
            throw new RuntimeException("Could not verify pet existence: " + ex.getMessage());
        }
    }

    private void validateFundraiserExists(Long fundraiserId, Long shelterId) {
        log.debug("Validating fundraiser {} for shelter {}", fundraiserId, shelterId);
        Fundraiser fundraiser = fundraiserRepository.findById(fundraiserId)
                .orElseThrow(() -> new ResourceNotFoundException("Fundraiser not found: " + fundraiserId));

        if (!fundraiser.getShelterId().equals(shelterId)) {
            throw new RuntimeException("Fundraiser does not belong to the specified shelter");
        }

        if (!fundraiser.canAcceptDonations()) {
            throw new RuntimeException("Fundraiser is not accepting donations in current state: " + fundraiser.getStatus());
        }

        log.debug("Fundraiser {} is valid for donations", fundraiserId);
    }

    private String getCurrentUsername() {
        var authentication = SecurityContextHolder.getContext().getAuthentication();
        if (authentication != null && authentication.getPrincipal() instanceof Jwt jwt) {
            return jwt.getSubject();
        }
        return authentication != null ? authentication.getName() : null;
    }

    private DonationRequest convertToDonationRequest(DonationIntentRequest request) {
        DonationRequest donationRequest = new DonationRequest();
        donationRequest.setShelterId(request.getShelterId());
        donationRequest.setPetId(request.getPetId());
        donationRequest.setFundraiserId(request.getFundraiserId());
        donationRequest.setDonationType(request.getDonationType());
        donationRequest.setMessage(request.getMessage());
        donationRequest.setAnonymous(request.getAnonymous());

        if (request.getDonationType() == DonationType.MONEY) {
            donationRequest.setAmount(request.getAmount());
        }

        if (request.getDonationType() == DonationType.MATERIAL) {
            donationRequest.setItemName(request.getItemName());
            donationRequest.setUnitPrice(request.getUnitPrice());
            donationRequest.setQuantity(request.getQuantity());
        }

        return donationRequest;
    }

    private void trackDonationAchievement(Donation donation) {
        try {
            String donorUsername = donation.getDonorUsername();
            if (donorUsername != null && !donorUsername.trim().isEmpty()) {
                achievementClient.trackSupportProgress();
                log.info("Tracked support achievement for donation {} by user: {}",
                        donation.getId(), donorUsername);
            }
        } catch (Exception e) {
            log.error("Failed to track achievement for donation {} by user: {}",
                    donation.getId(), donation.getDonorUsername(), e);
        }
    }
}