Rental.java

package com.github.jenkaby.bikerental.rental.domain.model;

import com.github.jenkaby.bikerental.rental.domain.exception.InvalidRentalStatusException;
import com.github.jenkaby.bikerental.rental.domain.exception.RentalNotReadyForActivationException;
import com.github.jenkaby.bikerental.rental.domain.service.RentalDurationCalculator;
import com.github.jenkaby.bikerental.rental.domain.service.RentalDurationResult;
import com.github.jenkaby.bikerental.shared.domain.RentalRef;
import com.github.jenkaby.bikerental.shared.domain.model.vo.DiscountPercent;
import com.github.jenkaby.bikerental.shared.domain.model.vo.Money;
import lombok.*;

import java.time.Duration;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.UUID;
import java.util.function.Function;
import java.util.function.Predicate;


@Getter
@Builder
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class Rental {

    private static final Predicate<RentalEquipment> RETURNED = e -> e.getStatus() == RentalEquipmentStatus.RETURNED;
    @Setter
    private Long id;

    private UUID customerId;
    private List<RentalEquipment> equipments;

    @Setter
    private RentalStatus status;

    private LocalDateTime startedAt;
    private LocalDateTime expectedReturnAt;
    private LocalDateTime actualReturnAt;

    private Duration plannedDuration;
    private Duration actualDuration;

    private Money estimatedCost;
    private Money finalCost;

    private Long specialTariffId;
    private Money specialPrice;
    private DiscountPercent discountPercent;

    private Instant createdAt;
    private Instant updatedAt;


    public static Rental createDraft() {
        return Rental.builder()
                .status(RentalStatus.DRAFT)
                .createdAt(Instant.now())
                .equipments(new ArrayList<>())
                .build();
    }

    public void selectCustomer(UUID customerId) {
        if (this.status != RentalStatus.DRAFT) {
            throw new InvalidRentalStatusException(this.status, RentalStatus.DRAFT);
        }
        this.customerId = customerId;
        this.updatedAt = Instant.now();
    }

    public void selectTariff(Long tariffId) {
        if (this.status != RentalStatus.DRAFT) {
            throw new InvalidRentalStatusException(this.status, RentalStatus.DRAFT);
        }
        this.updatedAt = Instant.now();
    }

    public void setPlannedDuration(Duration duration) {
        if (this.status != RentalStatus.DRAFT) {
            throw new InvalidRentalStatusException(this.status, RentalStatus.DRAFT);
        }
        this.plannedDuration = duration;
        // startedAt and expectedReturnAt will be set automatically when rental is activated
        this.updatedAt = Instant.now();
    }

    @Deprecated
    public void setEstimatedCost(Money estimatedCost) {
        if (this.status != RentalStatus.DRAFT) {
            throw new InvalidRentalStatusException(this.status, RentalStatus.DRAFT);
        }
        this.estimatedCost = estimatedCost;
        this.updatedAt = Instant.now();
    }

    public Money getEstimatedCost() {
        return calculateCost(RentalEquipment::getEstimatedCost);
    }

    public Money getFinalCost() {
        return calculateCost(RentalEquipment::getFinalCost);
    }

    private Money calculateCost(Function<RentalEquipment, Money> costExtractor) {
        if (specialPrice != null) {
            return specialPrice;
        }
        var subtotal = this.equipments.stream()
                .map(costExtractor)
                .filter(java.util.Objects::nonNull)
                .reduce(Money.zero(), Money::add);
        if (discountPercent != null) {
            return subtotal.subtract(discountPercent.multiply(subtotal));
        }
        return subtotal;
    }

    public RentalRef toRentalRef() {
        return new RentalRef(id);
    }

    @Deprecated
    public boolean isPrepaymentSufficient(Money amount) {
        if (estimatedCost == null) {
            return false;
        }
        return amount.compareTo(estimatedCost) >= 0;
    }

    public boolean hasActiveStatus() {
        return status == RentalStatus.ACTIVE;
    }

    public boolean canBeActivated() {
        boolean hasEquipment = !isEmpty(equipments);
        return status == RentalStatus.DRAFT
                && customerId != null
                && hasEquipment
                && plannedDuration != null
                && estimatedCost != null;
    }

    public void activate(LocalDateTime actualStartTime) {
        // Validate status
        if (this.status != RentalStatus.DRAFT) {
            throw new InvalidRentalStatusException(this.status, RentalStatus.DRAFT);
        }

        // Validate required fields
        if (!canBeActivated()) {
            List<String> missingFields = new ArrayList<>();
            if (customerId == null) missingFields.add("customerId");
            if (plannedDuration == null) missingFields.add("plannedDuration");
            if (estimatedCost == null) missingFields.add("estimatedCost");
            if (equipments == null) missingFields.add("equipmentIds");
            throw new RentalNotReadyForActivationException(missingFields);
        }

        this.status = RentalStatus.ACTIVE;
        this.startedAt = actualStartTime; // Actual start time
        this.expectedReturnAt = actualStartTime.plus(this.plannedDuration);

        equipments.forEach(e -> e.activateForRental(this));
        this.updatedAt = Instant.now();
    }

    // For cases update rental during patch
    public void clearEquipmentRentals() {
        this.equipments.clear();
    }

    public void addEquipment(RentalEquipment equipment) {
        if (this.status != RentalStatus.DRAFT) {
            throw new InvalidRentalStatusException(this.status, RentalStatus.DRAFT);
        }
        this.equipments.add(equipment);
        this.updatedAt = Instant.now();
    }

    public boolean allEquipmentReturned() {
        return equipments.stream()
                .allMatch(RETURNED);
    }

    private List<RentalEquipment> rentedEquipments() {
        return equipments.stream()
                .filter(Predicate.not(RETURNED))
                .toList();
    }

    public List<RentalEquipment> equipmentsToReturn(List<Long> toReturnEquipmentIds, List<String> toReturnEquipmentUids, LocalDateTime returnedAt) {
//         assume when no equipments are present in request, entire rental must be completed
        var isEmptyRequest = isEmpty(toReturnEquipmentIds) && isEmpty(toReturnEquipmentUids);
        Predicate<RentalEquipment> filter = eq -> isEmptyRequest
                || toReturnEquipmentIds.contains(eq.getEquipmentId())
                || toReturnEquipmentUids.contains(eq.getEquipmentUid());
        return rentedEquipments().stream()
                .filter(filter)
                .map(eq -> eq.markReturned(returnedAt))
                .toList();
    }

    public RentalDurationResult calculateActualDuration(RentalDurationCalculator calculator, LocalDateTime returnTime) {
        if (this.status != RentalStatus.ACTIVE && this.status != RentalStatus.COMPLETED) {
            throw new InvalidRentalStatusException(this.status, RentalStatus.ACTIVE);
        }

        if (this.startedAt == null) {
            throw new IllegalStateException("Cannot calculate duration: rental start time is not set");
        }

        RentalDurationResult result = calculator.calculate(this.startedAt, returnTime);
        this.actualDuration = result.actualDuration();
        this.actualReturnAt = returnTime;
        this.updatedAt = Instant.now();

        return result;
    }

    public void completeForDebt() {
        if (this.finalCost == null) {
            throw new IllegalArgumentException("Final cost cannot be null");
        }
        this.updatedAt = Instant.now();
        this.status = RentalStatus.COMPLETED;
    }

    public void completeWithStatus(Money finalCost, RentalStatus status) {
        validateCompletion(finalCost);

        this.finalCost = finalCost;
        if (allEquipmentReturned()) {
            this.status = status;
        }
        this.updatedAt = Instant.now();
    }

    private void validateCompletion(Money finalCost) {
        if (this.status != RentalStatus.ACTIVE && this.status != RentalStatus.DEBT) {
            throw new InvalidRentalStatusException(this.status, RentalStatus.ACTIVE);
        }

        if (this.startedAt == null) {
            throw new IllegalStateException("Cannot complete rental: rental start time is not set");
        }

        if (finalCost == null) {
            throw new IllegalArgumentException("Final cost cannot be null");
        }

        if (this.actualReturnAt == null || this.actualDuration == null) {
            throw new IllegalStateException("Cannot complete rental: actual return time and duration must be set before completing");
        }
    }

    private static boolean isEmpty(Collection<?> collection) {
        return collection == null || collection.isEmpty();
    }

    public static class RentalBuilder {
        public Rental build() {
            if (specialTariffId != null && discountPercent != null) {
                throw new IllegalArgumentException(
                        "specialTariffId and discountPercent are mutually exclusive");
            }
            if (specialTariffId != null && specialPrice == null) {
                throw new IllegalArgumentException(
                        "specialPrice is required when specialTariffId is set");
            }
            return new Rental(id, customerId, equipments, status, startedAt, expectedReturnAt,
                    actualReturnAt, plannedDuration, actualDuration, estimatedCost, finalCost,
                    specialTariffId, specialPrice, discountPercent, createdAt, updatedAt);
        }
    }
}