MatchManagementServiceImpl.java

package net.andresbustamante.yafoot.core.services.impl;

import net.andresbustamante.yafoot.commons.exceptions.ApplicationException;
import net.andresbustamante.yafoot.commons.exceptions.DatabaseException;
import net.andresbustamante.yafoot.commons.model.UserContext;
import net.andresbustamante.yafoot.core.dao.CarDao;
import net.andresbustamante.yafoot.core.dao.MatchDao;
import net.andresbustamante.yafoot.core.dao.PlayerDao;
import net.andresbustamante.yafoot.core.dao.SiteDao;
import net.andresbustamante.yafoot.core.events.CarpoolingRequestEvent;
import net.andresbustamante.yafoot.core.events.MatchPlayerRegistrationEvent;
import net.andresbustamante.yafoot.core.events.MatchPlayerUnsubscriptionEvent;
import net.andresbustamante.yafoot.core.exceptions.PastMatchException;
import net.andresbustamante.yafoot.core.exceptions.UnauthorisedUserException;
import net.andresbustamante.yafoot.core.model.Car;
import net.andresbustamante.yafoot.core.model.Match;
import net.andresbustamante.yafoot.core.model.Player;
import net.andresbustamante.yafoot.core.model.Registration;
import net.andresbustamante.yafoot.core.model.Site;
import net.andresbustamante.yafoot.core.services.CarManagementService;
import net.andresbustamante.yafoot.core.services.CarpoolingService;
import net.andresbustamante.yafoot.core.services.MatchManagementService;
import net.andresbustamante.yafoot.core.services.SiteManagementService;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.text.RandomStringGenerator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

import static net.andresbustamante.yafoot.core.model.enums.MatchStatusEnum.CANCELLED;
import static net.andresbustamante.yafoot.core.model.enums.MatchStatusEnum.CREATED;

/**
 * @author andresbustamante
 */
@Service
public class MatchManagementServiceImpl implements MatchManagementService {

    private static final Integer CODE_LENGTH = 10;

    private final RandomStringGenerator codeGenerator =
            new RandomStringGenerator.Builder().withinRange('A', 'Z').build();

    private final Logger log = LoggerFactory.getLogger(MatchManagementServiceImpl.class);

    private final MatchDao matchDAO;
    private final SiteDao siteDAO;
    private final SiteManagementService siteManagementService;
    private final CarDao carDAO;
    private final CarManagementService carManagementService;
    private final CarpoolingService carpoolingService;
    private final PlayerDao playerDAO;
    private final RabbitTemplate rabbitTemplate;

    @Value("${app.messaging.queues.matches.registrations.name}")
    private String matchPlayerRegistrationsQueue;

    @Value("${app.messaging.queues.matches.unsubscriptions.name}")
    private String matchPlayerUnregistrationsQueue;

    @Value("${app.messaging.queues.carpooling.requests.name}")
    private String carpoolingRequestsQueue;

    public MatchManagementServiceImpl(
            final MatchDao matchDAO, final SiteDao siteDAO, final CarDao carDAO, final PlayerDao playerDAO,
            final SiteManagementService siteManagementService, final CarManagementService carManagementService,
            final CarpoolingService carpoolingService, final RabbitTemplate rabbitTemplate) {
        this.matchDAO = matchDAO;
        this.siteDAO = siteDAO;
        this.siteManagementService = siteManagementService;
        this.carDAO = carDAO;
        this.carManagementService = carManagementService;
        this.carpoolingService = carpoolingService;
        this.playerDAO = playerDAO;
        this.rabbitTemplate = rabbitTemplate;
    }

    @Transactional
    @Override
    public Integer saveMatch(final Match match, final UserContext userContext)
            throws DatabaseException, ApplicationException {
        String matchCode;

        if (match.getDate().isBefore(LocalDateTime.now())) {
            throw new ApplicationException("match.past.new.date.error", "A match cannot be planned in the past");
        }

        boolean isCodeAlreadyInUse;
        do {
            matchCode = generateMatchCode();
            isCodeAlreadyInUse = matchDAO.isCodeAlreadyRegistered(matchCode);
        } while (isCodeAlreadyInUse);

        match.setCode(matchCode);
        match.setStatus(CREATED);
        match.setRegistrations(new ArrayList<>());

        final Player creator = processCreatorToCreateMatch(match, userContext);

        processSiteToCreateMatch(match, userContext);

        matchDAO.saveMatch(match);
        log.info("New match registered with the ID number {}", match.getId());

        registerPlayer(creator, match, null, userContext);
        return match.getId();
    }

    @Transactional(rollbackFor = {ApplicationException.class, DatabaseException.class})
    @Override
    public void registerPlayer(final Player player, final Match match, final Car car, final UserContext userContext)
            throws ApplicationException, DatabaseException {
        if (!match.isAcceptingRegistrations()) {
            throw new ApplicationException("max.players.match.error", "This match is not accepting more registrations");
        } else if (match.isAcceptingRegistrations() && match.isPlayerRegistered(player)) {
            processCarpoolingImpacts(player, match, car, userContext);

            // Remove the existing registry to insert a new one with fresh information
            matchDAO.unregisterPlayer(player, match);
        }

        if (car != null) {
            boolean isCarConfirmed = processCarToJoinMatch(car, userContext);
            matchDAO.registerPlayer(player, match, car, isCarConfirmed);

            notifyRegisteredPlayer(player, match);

            if (match.isCarpoolingEnabled() && !isCarConfirmed) {
                // A confirmation is needed from the driver of the car selected for this operation
                carpoolingService.processCarSeatRequest(match, player, car, userContext);

                notifyCarpoolRequest(player, match, car);
            }
        } else {
            matchDAO.registerPlayer(player, match, null, null);
        }

        log.info("Player {} successfully registered to the match {}", player.getId(), match.getId());
    }

    /**
     * If carpooling is enabled for a match, it checks if carpooling is impacted by the new registration request
     * meaning that an existing player is changing his/her transportation options.
     *
     * @param player Player to check
     * @param match Match to check
     * @param car Car to process
     * @param userContext Context of the user making the registration
     * @throws ApplicationException
     */
    private void processCarpoolingImpacts(final Player player, final Match match, final Car car,
                                          final UserContext userContext)
            throws ApplicationException {
        if (match.isCarpoolingEnabled()) {
            // Check if an update of carpooling must be made when a driver changes of transportation option
            Registration oldRegistration = matchDAO.loadRegistration(match, player);

            if (oldRegistration != null && oldRegistration.getCar() != null && player.equals(
                    oldRegistration.getCar().getDriver())) {
                // The driver already registered is changing of mind
                carpoolingService.processTransportationChange(match, oldRegistration.getCar(), car, userContext);
            }
        }
    }

    @Transactional
    @Override
    public void unregisterPlayer(final Player player, final Match match, final UserContext ctx)
            throws DatabaseException, ApplicationException {
        // Two players are authorised to unregister a player: himself/herself or the player who created the match
        boolean isUserAuthorised = ctx.getUsername().equals(match.getCreator().getEmail()) || ctx.getUsername().equals(
                player.getEmail());

        if (isUserAuthorised &&  match.isPlayerRegistered(player)) {
            if (match.isCarpoolingEnabled()) {
                processCarpoolingImpactsAfterAbandon(player, match, ctx);
            }
            matchDAO.unregisterPlayer(player, match);
            log.info("Player #{} was unregistered from match #{}", player.getId(), match.getId());

            notifyUnregisteredPlayer(player, match);
        } else {
            throw new ApplicationException("unknown.player.registration.error", "Player not registered in this match");
        }
    }

    /**
     * Update carpooling information on registration impacted by the abandon of a given player from a given match.
     * If the player is a driver from a car being confirmed for other players, the system updates their registrations
     * according to this abandon.
     *
     * @param player Player quitting the match
     * @param match Match being abandoned by the player
     * @param ctx User context
     */
    private void processCarpoolingImpactsAfterAbandon(final Player player, final Match match, final UserContext ctx)
            throws DatabaseException, ApplicationException {
        List<Car> registeredCars = carpoolingService.findAvailableCarsByMatch(match);

        if (CollectionUtils.isNotEmpty(registeredCars)) {
            Optional<Car> ownedCar = registeredCars.stream().filter(
                    car -> car.getDriver().equals(player)
            ).findFirst();

            if (ownedCar.isPresent()) {
                // The system found a car driven by the player
                List<Registration> impactedRegistrations = matchDAO.findPassengerRegistrationsByCar(match,
                        ownedCar.get());

                if (CollectionUtils.isNotEmpty(impactedRegistrations)) {
                    // At least one player asked or was confirmed for a seat in this car
                    // Update carpooling information to remove confirmations on this car
                    for (Registration registration : impactedRegistrations) {
                        carpoolingService.updateCarpoolingInformation(match, registration.getPlayer(), ownedCar.get(),
                                false, ctx);
                    }
                }
            }
        }
    }

    @Transactional
    @Override
    public void unregisterPlayerFromAllMatches(final Player player, final UserContext userContext) {
        int numMatches = matchDAO.unregisterPlayerFromAllMatches(player);
        log.info("Player #{} unregistered from {} matches", player.getId(), numMatches);
    }

    @Transactional
    @Override
    public void cancelMatch(final Match match, final UserContext userContext) throws ApplicationException {
        if (match.getDate().isBefore(LocalDateTime.now())) {
            throw new PastMatchException("It is not possible to cancel a match in the past");
        } else if (match.getCreator() == null || !match.getCreator().getEmail().equals(userContext.getUsername())) {
            throw new UnauthorisedUserException("This match can only be cancelled by its creator");
        }

        match.setStatus(CANCELLED);
        matchDAO.updateMatchStatus(match);
        log.info("Match {} with code {} successfully cancelled", match.getId(), match.getCode());
    }

    private Player processCreatorToCreateMatch(final Match match, final UserContext userContext)
            throws DatabaseException {
        Player creator = playerDAO.findPlayerByEmail(userContext.getUsername());

        if (creator != null) {
            match.setCreator(creator);
        } else {
            throw new DatabaseException("User not found in DB: " + userContext.getUsername());
        }
        return creator;
    }

    private void processSiteToCreateMatch(final Match match, final UserContext userContext) throws DatabaseException {
        if (match.getSite().getId() != null) {
            Site storedSite = siteDAO.findSiteById(match.getSite().getId());

            if (storedSite != null) {
                match.setSite(storedSite);
            } else {
                throw new DatabaseException("Site not found in DB: " + match.getSite().getId());
            }
        } else {
            siteManagementService.saveSite(match.getSite(), userContext);
        }
    }

    /**
     * Loads or saves the car used to join a match.
     *
     * @param car The car used by the registration
     * @param userContext
     * @return Indicates if the car is confirmed for the current player after processing the car
     * @throws DatabaseException
     */
    private boolean processCarToJoinMatch(final Car car, final UserContext userContext) throws DatabaseException {
        if (car.getId() != null) {
            // Look for the car in DB
            Car storedCar = carDAO.findCarById(car.getId());

            if (storedCar == null) {
                throw new DatabaseException("Car not found in DB: " + car.getId());
            } else {
                car.setName(storedCar.getName());
                car.setNumSeats(storedCar.getNumSeats());
                car.setDriver(storedCar.getDriver());
            }
        } else {
            // Save the new car
            carManagementService.saveCar(car, userContext);
        }

        // Case 1: No confirmation needed for the driver of a car. This car is confirmed
        // Case 2: This car doesn't belong to the user registering to the match. He/She is not confirmed yet
        return car.getDriver() != null && car.getDriver().getEmail().equals(userContext.getUsername());
    }

    /**
     * Generates a new alphabetic code for a match by using the maximum length as marked in CODE_LENGTH.
     *
     * @return New alphabetic random code
     */
    private String generateMatchCode() {
        log.info("Generating new match code");
        return codeGenerator.generate(CODE_LENGTH);
    }

    /**
     * Sends a notification to the message broker after for this action.
     *
     * @param player Player that joined the match
     * @param match The match to update
     */
    private void notifyRegisteredPlayer(final Player player, final Match match) {
        MatchPlayerRegistrationEvent event = new MatchPlayerRegistrationEvent();
        event.setPlayerFirstName(player.getFirstName());
        event.setPlayerId(player.getId());
        event.setMatchId(match.getId());
        event.setMatchCode(match.getCode());

        rabbitTemplate.convertAndSend(matchPlayerRegistrationsQueue, event);
    }

    /**
     * Sends a notification to the message broker after for this action.
     *
     * @param player Player that left the match
     * @param match The match to update
     */
    private void notifyUnregisteredPlayer(final Player player, final Match match) {
        MatchPlayerUnsubscriptionEvent event = new MatchPlayerUnsubscriptionEvent();
        event.setPlayerFirstName(player.getFirstName());
        event.setPlayerId(player.getId());
        event.setMatchId(match.getId());
        event.setMatchCode(match.getCode());

        rabbitTemplate.convertAndSend(matchPlayerUnregistrationsQueue, event);
    }

    /**
     * Sends a notification of carpooling from a player for a given match in a given car.
     *
     * @param player Player asking for carpooling
     * @param match Match to ask for
     * @param car Selected car for the request
     */
    private void notifyCarpoolRequest(final Player player, final Match match, final Car car) {
        CarpoolingRequestEvent event = new CarpoolingRequestEvent();
        event.setMatchId(match.getId());
        event.setMatchCode(match.getCode());
        event.setPlayerId(player.getId());
        event.setPlayerFirstName(player.getFirstName());
        event.setCarId(car.getId());
        event.setCarName(car.getName());

        rabbitTemplate.convertAndSend(carpoolingRequestsQueue, event);
    }
}