ReservationServiceImpl.java

package cl.prgm.portaltrip.application.service;

import java.time.OffsetDateTime;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ThreadLocalRandom;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import cl.prgm.portaltrip.domain.exception.IdempotencyConflictException;
import cl.prgm.portaltrip.domain.exception.ResourceNotFoundException;
import cl.prgm.portaltrip.domain.model.Character;
import cl.prgm.portaltrip.domain.model.Location;
import cl.prgm.portaltrip.domain.model.Quote;
import cl.prgm.portaltrip.domain.model.Reservation;
import cl.prgm.portaltrip.domain.model.ReservationBalanceResult;
import cl.prgm.portaltrip.domain.model.ReservationDraft;
import cl.prgm.portaltrip.domain.model.UserAccount;
import cl.prgm.portaltrip.domain.service.QuoteCalculator;
import cl.prgm.portaltrip.infrastructure.persistence.CharacterEntity;
import cl.prgm.portaltrip.infrastructure.persistence.LocationEntity;
import cl.prgm.portaltrip.infrastructure.persistence.ReservationEntity;
import cl.prgm.portaltrip.infrastructure.persistence.UserEntity;
import cl.prgm.portaltrip.infrastructure.persistence.repository.CharacterJpaRepository;
import cl.prgm.portaltrip.infrastructure.persistence.repository.LocationJpaRepository;
import cl.prgm.portaltrip.infrastructure.persistence.repository.ReservationJpaRepository;
import cl.prgm.portaltrip.infrastructure.persistence.repository.UserJpaRepository;

@Service
public class ReservationServiceImpl implements ReservationService {

	private final ReservationJpaRepository reservationJpaRepository;
	private final LocationJpaRepository locationJpaRepository;
	private final CharacterJpaRepository characterJpaRepository;
	private final UserJpaRepository userJpaRepository;
	private final QuoteCalculator quoteCalculator = new QuoteCalculator();

	public ReservationServiceImpl(
			ReservationJpaRepository reservationJpaRepository,
			LocationJpaRepository locationJpaRepository,
			CharacterJpaRepository characterJpaRepository,
			UserJpaRepository userJpaRepository) {
		this.reservationJpaRepository = reservationJpaRepository;
		this.locationJpaRepository = locationJpaRepository;
		this.characterJpaRepository = characterJpaRepository;
		this.userJpaRepository = userJpaRepository;
	}

	@Override
	@Transactional
	public ReservationBalanceResult create(UUID userId, UUID idempotencyKey, ReservationDraft draft) {
		UserEntity userEntity = getUserForUpdate(userId);
		ReservationEntity repeated = reservationJpaRepository
				.findByUserIdAndIdempotencyKey(userId, idempotencyKey)
				.orElse(null);
		if (repeated != null) {
			Reservation reservation = repeated.toDomain();
			if (!reservation.matches(draft)) {
				throw new IdempotencyConflictException();
			}
			return new ReservationBalanceResult(reservation, userEntity.toDomain().balance());
		}

		LocationEntity destinationEntity = locationJpaRepository.findDetailedById(draft.destinationId())
				.orElseThrow(() -> new ResourceNotFoundException("Location", draft.destinationId()));
		Location destination = destinationEntity.toDomain();
		List<CharacterEntity> companionEntities = characterJpaRepository.findAllById(draft.companionIds());
		List<Integer> foundCompanionIds = companionEntities.stream()
				.map(CharacterEntity::getId)
				.toList();
		draft.companionIds().stream()
				.filter(id -> !foundCompanionIds.contains(id))
				.findFirst()
				.ifPresent(id -> {
					throw new ResourceNotFoundException("Character", id);
				});
		List<Character> companions = companionEntities.stream()
				.map(CharacterEntity::toSummary)
				.toList();
		Quote quote = quoteCalculator.calculate(
				draft.passengers(),
				draft.tripType(),
				draft.insurance(),
				destination.type(),
				destination.dimension(),
				destination.residentIds().size());
		OffsetDateTime now = OffsetDateTime.now();
		UserAccount debitedUser = userEntity.toDomain().debit(quote.total(), now);
		userEntity.apply(debitedUser);
		Reservation reservation = Reservation.confirm(
				draft,
				userId,
				debitedUser.email(),
				idempotencyKey,
				destination,
				companions,
				quote,
				UUID.randomUUID(),
				generateNumber(),
				now);
		return new ReservationBalanceResult(save(reservation, userEntity), debitedUser.balance());
	}

	@Override
	@Transactional(readOnly = true)
	public List<Reservation> findAll(UUID userId) {
		return reservationJpaRepository.findAllDetailedByUserId(userId).stream()
				.map(ReservationEntity::toDomain)
				.toList();
	}

	@Override
	@Transactional(readOnly = true)
	public Reservation findById(UUID userId, UUID id) {
		return findEntity(userId, id).toDomain();
	}

	private ReservationEntity findEntity(UUID userId, UUID id) {
		return reservationJpaRepository.findDetailedByIdAndUserId(id, userId)
				.orElseThrow(() -> new ResourceNotFoundException("Reservation", id));
	}

	@Override
	@Transactional
	public ReservationBalanceResult cancel(UUID userId, UUID id) {
		Reservation cancelled = findEntity(userId, id).toDomain().cancel();
		UserEntity userEntity = getUserForUpdate(userId);
		UserAccount creditedUser = userEntity.toDomain().credit(cancelled.quote().total(), OffsetDateTime.now());
		userEntity.apply(creditedUser);
		return new ReservationBalanceResult(save(cancelled, userEntity), creditedUser.balance());
	}

	@Override
	@Transactional
	public Reservation start(UUID userId, UUID id) {
		Reservation reservation = findEntity(userId, id).toDomain().start(OffsetDateTime.now());
		return save(reservation, getUser(userId));
	}

	@Override
	@Transactional
	public Reservation complete(UUID userId, UUID id) {
		Reservation reservation = findEntity(userId, id).toDomain().complete(OffsetDateTime.now());
		return save(reservation, getUser(userId));
	}

	private Reservation save(Reservation reservation, UserEntity user) {
		LocationEntity destination = locationJpaRepository.getReferenceById(reservation.destinationId());
		Set<CharacterEntity> companions = new LinkedHashSet<>(
				characterJpaRepository.findAllById(reservation.companionIds()));
		ReservationEntity entity = ReservationEntity.fromDomain(reservation, user, destination, companions);
		return reservationJpaRepository.save(entity).toDomain();
	}

	private UserEntity getUser(UUID userId) {
		return userJpaRepository.findById(userId)
				.orElseThrow(() -> new ResourceNotFoundException("User", userId));
	}

	private UserEntity getUserForUpdate(UUID userId) {
		return userJpaRepository.findByIdForUpdate(userId)
				.orElseThrow(() -> new ResourceNotFoundException("User", userId));
	}

	private String generateNumber() {
		String number;
		do {
			number = "PT-%d-%06d".formatted(
					OffsetDateTime.now().getYear(),
					ThreadLocalRandom.current().nextInt(1_000_000));
		} while (reservationJpaRepository.existsByNumber(number));
		return number;
	}

}