TourGuideService.java

package com.openclassrooms.tourguide.service;

import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Random;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.openclassrooms.tourguide.helper.InternalTestHelper;
import com.openclassrooms.tourguide.tracker.Tracker;
import com.openclassrooms.tourguide.user.User;
import com.openclassrooms.tourguide.user.UserReward;

import gpsUtil.GpsUtil;
import gpsUtil.location.Attraction;
import gpsUtil.location.Location;
import gpsUtil.location.VisitedLocation;
import tripPricer.Provider;
import tripPricer.TripPricer;

/**
 * Main service that manages the TourGuide app's features:
 * - Tracks user locations
 * - Assigns reward points
 * - Provides recommendations for nearby attractions
 * - Generates personalized travel offers
 */
@Service
public class TourGuideService {

	private final ExecutorService executor;
	private List<User> allUsers;
	private final Map<String, User> internalUserMap = new ConcurrentHashMap<>();
	private final GpsUtil gpsUtil;
	private final RewardsService rewardsService;
	private final TripPricer tripPricer = new TripPricer();
	public final Tracker tracker;
	private final boolean startTracker;

	private final Cache<UUID, VisitedLocation> locationCache = Caffeine.newBuilder()
			.expireAfterWrite(5, TimeUnit.MINUTES)
			.maximumSize(500_000)
			.build();

	private static final String TRIP_PRICER_API_KEY = "test-server-api-key";
	private final boolean testMode = true;

	/**
	 * Builder of the main TourGuide service.
	 *
	 * Initializes the necessary components
	 *
	 * @param gpsUtil         User geolocation service
	 * @param rewardsService  Rewards Management Service
	 * @param executorService Thread pool for asynchronous processing
	 * @param startTracker    Indicates whether to enable automatic user tracking
	 *
	 */

	public TourGuideService(GpsUtil gpsUtil, RewardsService rewardsService, ExecutorService executorService,
			@Value("${tourguide.startTracker:true}") boolean startTracker) {
		this.gpsUtil = gpsUtil;
		this.rewardsService = rewardsService;
		this.executor = executorService;
		this.startTracker = startTracker;

		Locale.setDefault(Locale.US);

		if (testMode) {
			initializeInternalUsers();
		}
		this.tracker = startTracker ? new Tracker(this) : null;
		if (startTracker) {
			Runtime.getRuntime().addShutdownHook(new Thread(() -> tracker.stopTracking()));
		}
	}

	/**
	 * Defines a new user list for the service.
	 *
	 * This method replaces the current users with those provided in
	 * parameters
	 *
	 * The list must not be null or empty, otherwise an error is thrown.
	 *
	 * @param allUsers Liste des utilisateurs à enregistrer
	 * @throws IllegalArgumentException if the list is null
	 * @throws IllegalStateException    if the list is empty
	 */

	public void setAllUsers(List<User> allUsers) {
		if (allUsers == null) {
			throw new IllegalArgumentException("User list must not be null.");
		}
		if (allUsers.isEmpty()) {
			throw new IllegalStateException("User list is empty. Cannot initialize users.");
		}
		this.allUsers = new ArrayList<>(allUsers);
	}

	/**
	 * Returns the list of rewards associated with a given user.
	 *
	 * @param user User for whom you want to get the rewards
	 * @return List of reward points earned by this user
	 */

	public List<UserReward> getUserRewards(User user) {
		return user.getUserRewards();
	}

	/**
	 * Returns the user's current location.
	 *
	 * If the user already has a saved location, it is returned.
	 * Otherwise, a new location is obtained in real time.
	 *
	 * @param user Concerned user
	 * @return Last known position or updated position
	 */

	public VisitedLocation getUserLocation(User user) {
		return (!user.getVisitedLocations().isEmpty()) ? user.getLastVisitedLocation() : trackUserLocation(user).join();
	}

	/**
	 * Starts location tracking for a given user.
	 *
	 * If a recent position is already available in the cache, it is used
	 * directly.
	 * Otherwise, a new position is retrieved from the GPS service, added to the
	 * user's history, and rewards are calculated in the background.
	 *
	 * @param user The user to locate
	 * @return A CompletableFuture containing the user's new (or old) position
	 */

	public CompletableFuture<VisitedLocation> trackUserLocation(User user) {
		return CompletableFuture.supplyAsync(() -> {
			VisitedLocation cachedLocation = locationCache.getIfPresent(user.getUserId());
			if (cachedLocation != null)
				return cachedLocation;

			VisitedLocation visitedLocation = gpsUtil.getUserLocation(user.getUserId());

			user.addToVisitedLocations(visitedLocation);
			List<Attraction> attractions = gpsUtil.getAttractions();
			rewardsService.calculateRewardsAsync(user, attractions);
			locationCache.put(user.getUserId(), visitedLocation);
			return visitedLocation;
		}, executor);
	}

	/**
	 * Starts location tracking for all provided users.
	 *
	 * Each user is located in parallel via asynchronous calls,
	 * and execution waits for all operations to complete before
	 * continuing.
	 *
	 * @param users List of users to follow
	 */

	public void trackAllUsersLocations(List<User> users) {
		List<CompletableFuture<Void>> futures = users.stream()
				.map(user -> trackUserLocation(user).thenAccept(location -> {
				}))
				.toList();

		CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
	}

	/**
	 * Returns the 5 closest attractions to the given position.
	 *
	 * This method sorts all known attractions by distance
	 * from the user's current location, then returns the top 5.
	 *
	 * @param visitedLocation Current position of the user
	 * @return List of the 5 nearest attractions
	 */

	public List<Attraction> getNearByAttractions(VisitedLocation visitedLocation) {
		return gpsUtil.getAttractions().stream()
				.sorted(Comparator.comparingDouble(
						attraction -> rewardsService.getDistance(attraction, visitedLocation.location)))
				.limit(5)
				.collect(Collectors.toList());
	}

	/**
	 * Search for a user by username.
	 *
	 * If the user exists in the internal list, it is returned.
	 * Otherwise, an error is thrown.
	 *
	 * @param userName Username to search for
	 * @return The user corresponding to the given name
	 * @throws IllegalArgumentException if no user is found
	 */

	public User getUser(String userName) {
		User user = internalUserMap.get(userName.trim());
		if (user == null) {
			throw new IllegalArgumentException("User " + userName + " not found");
		}
		return user;
	}

	/**
	 * Returns the full list of registered users.
	 *
	 * This method returns a new list containing all users
	 * currently stored in internal memory.
	 *
	 * @return List of all known users
	 */

	public List<User> getAllUsers() {
		return new ArrayList<>(internalUserMap.values());
	}

	/**
	 * Adds a user to the internal list if it does not already exist.
	 *
	 * If a user with the same name is already registered, they will not be
	 * replaced.
	 *
	 * @param user The user to add
	 */

	public void addUser(User user) {
		internalUserMap.putIfAbsent(user.getUserName(), user);
	}

	/**
	 * Generate a list of personalized travel offers for a user.
	 *
	 * The number of reward points is taken into account to calculate
	 * offers tailored to the user's profile (adults, children,
	 * duration).
	 * The list is then saved to the user's account.
	 *
	 * @param user The user for whom we want to get offers
	 * @return List of suppliers with their travel offers
	 */

	public List<Provider> getTripDeals(User user) {
		int rewardPoints = user.getUserRewards().stream().mapToInt(UserReward::getRewardPoints).sum();
		List<Provider> providers = tripPricer.getPrice(
				TRIP_PRICER_API_KEY,
				user.getUserId(),
				user.getUserPreferences().getNumberOfAdults(),
				user.getUserPreferences().getNumberOfChildren(),
				user.getUserPreferences().getTripDuration(),
				rewardPoints);
		user.setTripDeals(providers);
		return providers;
	}

	/**
	 * Initializes a list of internal test users.
	 *
	 * The number of users to be created is determined by a configuration value.
	 * Each user receives a username, a unique name, and a history of simulated
	 * locations.
	 * These users are added to the service's internal map.
	 */

	private void initializeInternalUsers() {
		int userCount = InternalTestHelper.getInternalUserNumber();
		for (int i = 0; i < userCount; i++) {
			String userName = "internalUser" + i;
			User user = new User(UUID.randomUUID(), userName, "000", userName + "@tourGuide.com");
			generateUserLocationHistory(user);
			internalUserMap.put(userName, user);
		}
	}

	/**
	 * Generates a random location history for a user.
	 *
	 * This method adds three simulated locations with randomly generated
	 * coordinates
	 * and dates to create a movement history.
	 *
	 * @param user The user to add the positions to
	 */

	private void generateUserLocationHistory(User user) {
		IntStream.range(0, 3).forEach(i -> {
			user.addToVisitedLocations(new VisitedLocation(user.getUserId(),
					new Location(generateRandomLatitude(), generateRandomLongitude()), getRandomTime()));
		});
	}

	/**
	 * Generates a random longitude between -180 and 180 degrees.
	 *
	 * This method is used to simulate geographic coordinates.
	 *
	 * @return A random longitude value
	 */

	private double generateRandomLongitude() {
		double leftLimit = -180;
		double rightLimit = 180;
		return leftLimit + new Random().nextDouble() * (rightLimit - leftLimit);
	}

	/**
	 * Generates a random latitude between -85.05 and 85.05 degrees.
	 *
	 * @return A random latitude value
	 */
	private double generateRandomLatitude() {
		double leftLimit = -85.05112878;
		double rightLimit = 85.05112878;
		return leftLimit + new Random().nextDouble() * (rightLimit - leftLimit);
	}

	/**
	 * Creates a random date within the last 30 days.
	 *
	 * @return A recent random date
	 */
	private Date getRandomTime() {
		LocalDateTime localDateTime = LocalDateTime.now().minusDays(new Random().nextInt(30));
		return Date.from(localDateTime.toInstant(ZoneOffset.UTC));
	}

	/**
	 * Stops user tracking if it is active.
	 */
	public void shutdown() {
		if (tracker != null) {
			tracker.stopTracking();
		}
	}

}