UserServiceImpl.java

package no.ntnu.idi.stud.savingsapp.service.impl;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.mail.MessagingException;
import lombok.extern.slf4j.Slf4j;
import no.ntnu.idi.stud.savingsapp.SparestiApplication;
import no.ntnu.idi.stud.savingsapp.bank.service.AccountService;
import no.ntnu.idi.stud.savingsapp.exception.auth.InvalidCredentialsException;
import no.ntnu.idi.stud.savingsapp.exception.user.EmailAlreadyExistsException;
import no.ntnu.idi.stud.savingsapp.exception.user.InvalidPasswordResetTokenException;
import no.ntnu.idi.stud.savingsapp.exception.user.UserException;
import no.ntnu.idi.stud.savingsapp.exception.user.UserNotFoundException;
import no.ntnu.idi.stud.savingsapp.model.user.Feedback;
import no.ntnu.idi.stud.savingsapp.model.user.Friend;
import no.ntnu.idi.stud.savingsapp.model.user.PasswordResetToken;
import no.ntnu.idi.stud.savingsapp.model.user.Point;
import no.ntnu.idi.stud.savingsapp.model.user.Role;
import no.ntnu.idi.stud.savingsapp.model.user.SearchFilter;
import no.ntnu.idi.stud.savingsapp.model.user.Streak;
import no.ntnu.idi.stud.savingsapp.model.user.SubscriptionLevel;
import no.ntnu.idi.stud.savingsapp.model.user.User;
import no.ntnu.idi.stud.savingsapp.repository.FeedbackRepository;
import no.ntnu.idi.stud.savingsapp.repository.PasswordResetTokenRepository;
import no.ntnu.idi.stud.savingsapp.repository.PointRepository;
import no.ntnu.idi.stud.savingsapp.repository.StreakRepository;
import no.ntnu.idi.stud.savingsapp.repository.UserRepository;
import no.ntnu.idi.stud.savingsapp.service.FriendService;
import no.ntnu.idi.stud.savingsapp.service.UserService;

import org.apache.http.HttpStatus;
import org.apache.http.NameValuePair;
import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.entity.ContentType;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.message.BasicNameValuePair;
import org.apache.http.util.EntityUtils;
import org.json.simple.JSONObject;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpHeaders;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.sql.Timestamp;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.Collections;
import java.util.Iterator;

/**
 * Implementation of the UserService interface for user-related operations.
 */
@Service
@Slf4j
public class UserServiceImpl implements UserService {

	private static final Duration PASSWORD_RESET_DURATION = Duration.ofHours(1);

	@Autowired
	private UserRepository userRepository;

	@Autowired
	private FriendService friendService;

	@Autowired
	private PointRepository pointRepository;

	@Autowired
	private StreakRepository streakRepository;

	@Autowired
	private PasswordResetTokenRepository tokenRepository;

	@Autowired
	private PasswordEncoder passwordEncoder;

	@Autowired
	private EmailService emailService;

	@Autowired
	AccountService accountService;

	@Autowired
	FeedbackRepository feedbackRepository;

	/**
	 * Authenticates a user with the provided email and password.
	 * @param email The email address of the user.
	 * @param password The password associated with the user's account.
	 * @return The authenticated user object if login is successful.
	 * @throws InvalidCredentialsException if the provided credentials are invalid.
	 * @throws UserNotFoundException if the user with the provided email is not found.
	 */
	@Override
	public User login(String email, String password) {
		Optional<User> optionalUser = userRepository.findByEmail(email);
		if (optionalUser.isPresent()) {
			User user = optionalUser.get();
			boolean match = passwordEncoder.matches(password, user.getPassword());
			if (match) {
				return user;
			}
			else {
				log.error("[UserServiceImpl:login] invalid credentials: email: {}, password: {}", email, password);
				throw new InvalidCredentialsException();
			}
		}
		else {
			log.error("[UserServiceImpl:login] user is not found, email: {}", email);
			throw new UserNotFoundException();
		}
	}

	/**
	 * Registers a new user.
	 * @param user The user object containing registration information.
	 * @return The registered user object.
	 * @throws EmailAlreadyExistsException if the provided email already exists in the
	 * system.
	 */
	@Override
	public User register(User user) {
		String encodedPassword = passwordEncoder.encode(user.getPassword());
		user.setPassword(encodedPassword);
		user.setRole(Role.USER);
		user.setCreatedAt(Timestamp.from(Instant.now()));

		// Create and save a new Point object
		Point point = new Point();
		point.setCurrentPoints(0);
		point.setTotalEarnedPoints(0);
		point = pointRepository.save(point);

		user.setPoint(point);

		Streak streak = new Streak();
		streak.setCurrentStreak(0);
		streak.setCurrentStreakCreatedAt(Timestamp.from(Instant.now()));
		streak.setCurrentStreakUpdatedAt(Timestamp.from(Instant.now()));
		streak.setHighestStreak(0);
		streak.setHighestStreakCreatedAt(Timestamp.from(Instant.now()));
		streak.setHighestStreakEndedAt(Timestamp.from(Instant.now()));
		streak = streakRepository.save(streak);

		user.setStreak(streak);
		try {
			return userRepository.save(user);
		}
		catch (DataIntegrityViolationException e) {
			log.error("[UserServiceImpl:register] email already exists: {}", user.getEmail());
			throw new EmailAlreadyExistsException();
		}
	}

	/**
	 * Authenticates a user by exchanging a BankID authorization code for an access token
	 * and retrieves user information. This method contacts the BankID service to obtain
	 * an access token using the provided authorization code and then fetches the user
	 * details from the BankID userinfo endpoint. If the user is new, it registers them in
	 * the database.
	 * @param code The authorization code provided by the BankID authentication flow.
	 * @param state The state parameter to ensure the response corresponds to the request
	 * made by the user.
	 * @return A {@link User} object populated with details retrieved from BankID if
	 * authentication is successful, or null if an error occurs during the process.
	 */
	@Override
	public User bankIDAuth(String code, String state) {
		try {
			String tokenUrl = "https://preprod.signicat.com/oidc/token";
			HttpPost httpPost = new HttpPost(tokenUrl);
			httpPost.setHeader(HttpHeaders.ACCEPT, ContentType.APPLICATION_JSON.toString());
			httpPost.setHeader(HttpHeaders.AUTHORIZATION,
					"Basic " + "ZGVtby1wcmVwcm9kOm1xWi1fNzUtZjJ3TnNpUVRPTmI3T240YUFaN3pjMjE4bXJSVmsxb3VmYTg=");
			List<NameValuePair> params = new ArrayList<>();
			params.add(new BasicNameValuePair("client_id", "demo-preprod"));
			params.add(new BasicNameValuePair("redirect_uri", SparestiApplication.getBackendURL() + "/redirect"));
			params.add(new BasicNameValuePair("grant_type", "authorization_code"));
			params.add(new BasicNameValuePair("code", code));
			httpPost.setEntity(new UrlEncodedFormEntity(params, StandardCharsets.UTF_8));
			final String content = postRequest(httpPost);
			// parse response
			JSONObject jsonObject = new ObjectMapper().readValue(content, JSONObject.class);
			String accessToken = (String) jsonObject.get("access_token");

			// get userinfo
			HttpPost httpPost_userinfo = new HttpPost("https://preprod.signicat.com/oidc/userinfo");
			httpPost_userinfo.setHeader(HttpHeaders.ACCEPT, ContentType.APPLICATION_JSON.toString());
			httpPost_userinfo.setHeader(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken);
			final String content_userinfo = postRequest(httpPost_userinfo);
			JSONObject jsonObject2 = new ObjectMapper().readValue(content_userinfo, JSONObject.class);

			String sub = (String) jsonObject2.get("sub");
			Optional<User> optionalUser = userRepository.findByBankIdSub(sub);
			if (optionalUser.isPresent()) {
				return optionalUser.get();
			}
			User user = new User();
			user.setRole(Role.USER);
			user.setCreatedAt(Timestamp.from(Instant.now()));
			user.setBankIdSub(sub);
			user.setFirstName((String) jsonObject2.get("given_name"));
			user.setLastName((String) jsonObject2.get("family_name"));
			userRepository.save(user);
			return user;
		}
		catch (Exception e) {
			log.error("[UserServiceImpl:bankIDAuth] an error occurred");
			e.printStackTrace();
		}
		return null;
	}

	/**
	 * Sends an HTTP POST request and returns the response content as a string. This
	 * method is used internally to communicate with external services like BankID.
	 * @param httpPost The {@link HttpPost} object configured with the URL, headers, and
	 * body of the request.
	 * @return A string containing the response body.
	 * @throws Exception If the HTTP request fails or the server response indicates an
	 * error.
	 */
	protected String postRequest(final HttpPost httpPost) throws Exception {
		try {
			CloseableHttpClient httpClient = HttpClientBuilder.create().useSystemProperties().build();
			CloseableHttpResponse httpResponse = httpClient.execute(httpPost);
			final int status = httpResponse.getStatusLine().getStatusCode();
			if (status == HttpStatus.SC_FORBIDDEN || status / 100 != 2) {
				log.error("[UserServiceImpl:postRequest] an error occurred");
				throw new Exception("Something went wrong!! Handle this properly!!!");
			}
			final String content = EntityUtils.toString(httpResponse.getEntity(), StandardCharsets.UTF_8);
			return content;
		}
		catch (final Exception e) {
			log.error("[UserServiceImpl:postRequest] an error occurred");
			throw new Exception(e.getMessage());
		}
	}

	/**
	 * Updates the details of an existing user in the database.
	 * @param user The user object containing updated fields that should be persisted.
	 * @return The updated user object as persisted in the database.
	 * @throws EmailAlreadyExistsException If an attempt to update the user data results
	 * in a violation of unique constraint for the email field.
	 */
	@Override
	public User update(User user) {
		try {
			return userRepository.save(user);
		}
		catch (DataIntegrityViolationException e) {
			log.error("[UserServiceImpl:update] data integrity violation: {}", e.getMostSpecificCause().getMessage());
			throw new UserException(e.getMostSpecificCause().getMessage());
		}
	}

	/**
	 * Deletes a user from the system based on the specified user ID. This method
	 * permanently removes the user's record from the database. It should be used with
	 * caution, as this operation is irreversible and results in the loss of all data
	 * associated with the user's account.
	 * @param userId The unique identifier of the user to be deleted.
	 */
	@Override
	public void delete(long userId) {
		userRepository.deleteById(userId);
	}

	/**
	 * Updates the password of a user.
	 * @param id The ID of the user
	 * @param oldPassword The old password
	 * @param newPassword The new password
	 * @return The updated User object, persisted in the database.
	 * @throws InvalidCredentialsException if the old password is invalid.
	 */
	@Override
	public User updatePassword(long id, String oldPassword, String newPassword) {
		User user = findById(id);
		boolean match = passwordEncoder.matches(oldPassword, user.getPassword());
		if (match) {
			String encodedPassword = passwordEncoder.encode(newPassword);
			user.setPassword(encodedPassword);
		}
		else {
			log.error("[UserServiceImpl:updatePassword] invalid old password: {}", oldPassword);
			throw new InvalidCredentialsException("Old password is invalid");
		}
		return userRepository.save(user);
	}

	/**
	 * Retrieves a user by their email address.
	 * @param email The email address to search for in the database.
	 * @return The user object associated with the specified email address.
	 * @throws UserNotFoundException If no user is found associated with the provided
	 * email address.
	 */
	@Override
	public User findByEmail(String email) {
		Optional<User> optionalUser = userRepository.findByEmail(email);
		if (optionalUser.isPresent()) {
			return optionalUser.get();
		}
		else {
			log.error("[UserServiceImpl:findByEmail] user is not found: {}", email);
			throw new UserNotFoundException();
		}
	}

	/**
	 * Retrieves a user by their unique identifier.
	 * @param userId The unique ID of the user to retrieve.
	 * @return The user object associated with the specified ID.
	 * @throws UserNotFoundException If no user is found with the specified ID.
	 */
	@Override
	public User findById(long userId) {
		Optional<User> optionalUser = userRepository.findById(userId);
		if (optionalUser.isPresent()) {
			return optionalUser.get();
		}
		else {
			log.error("[UserServiceImpl:findById] user is not found: {}", userId);
			throw new UserNotFoundException();
		}
	}

	/**
	 * Initiates the password reset process by generating a reset token and sending an
	 * email.
	 * @param email The email of the user requesting a password reset.
	 */
	@Override
	public void initiatePasswordReset(String email) {
		User user = findByEmail(email);
		PasswordResetToken resetToken = new PasswordResetToken();
		resetToken.setUser(user);
		String token = UUID.randomUUID().toString();
		resetToken.setToken(token);
		resetToken.setCreatedAt(Timestamp.from(Instant.now()));
		try {
			tokenRepository.save(resetToken);
		}
		catch (DataIntegrityViolationException e) {
			log.error("[UserServiceImpl:initiatePasswordReset] error generating token");
			throw new RuntimeException("Error generating token");
		}
		try {
			emailService.sendForgotPasswordEmail(email, token);
		}
		catch (MessagingException | IOException e) {
			log.error("[UserServiceImpl:initiatePasswordReset] an error occurred");
			throw new RuntimeException(e.getMessage());
		}
	}

	/**
	 * Confirms and processes the password reset request by updating the user's password.
	 * @param token The reset token provided by the user.
	 * @param password The new password to be set for the user.
	 * @throws InvalidPasswordResetTokenException If the token is expired or invalid.
	 */
	@Override
	public void confirmPasswordReset(String token, String password) {
		Optional<PasswordResetToken> optionalResetToken = tokenRepository.findByToken(token);
		if (optionalResetToken.isPresent()) {
			PasswordResetToken resetToken = optionalResetToken.get();

			LocalDateTime tokenCreationDate = resetToken.getCreatedAt().toLocalDateTime();
			Duration durationBetween = Duration.between(tokenCreationDate, LocalDateTime.now());
			if (durationBetween.isNegative() || durationBetween.compareTo(PASSWORD_RESET_DURATION) > 0) {
				log.error("[UserServiceImpl:confirmPasswordReset] invalid token: {}", token);
				throw new InvalidPasswordResetTokenException();
			}

			User user = resetToken.getUser();
			user.setPassword(passwordEncoder.encode(password));
			userRepository.save(user);
		}
		else {
			log.error("[UserServiceImpl:confirmPasswordReset] invalid token: {}", token);
			throw new InvalidPasswordResetTokenException();
		}
	}

	/**
	 * Retrieves a list of {@link User} objects representing the friends of the specified
	 * user.
	 * @param userId The ID of the user whose friends are to be retrieved
	 * @return a list of {@link User} instances representing the user's friends
	 */
	@Override
	public List<User> getFriends(Long userId) {
		List<Friend> friendsFriend = friendService.getFriends(userId);
		List<User> friendsUser = new ArrayList<>();

		for (Friend friend : friendsFriend) {
			if (friend.getId().getUser().getId() != userId) {
				friendsUser.add(friend.getId().getUser());
			}
			else {
				friendsUser.add(friend.getId().getFriend());
			}
		}
		return friendsUser;
	}

	/**
	 * Retrieves a list of {@link User} objects representing the friend requests of the
	 * specified user.
	 * @param userId The ID of the user whose friend requests are to be retrieved
	 * @return a list of {@link User} instances representing the user's friend requests
	 */
	@Override
	public List<User> getFriendRequests(Long userId) {
		List<Friend> friendsFriend = friendService.getFriendRequests(userId);
		List<User> friendsUser = new ArrayList<>();

		for (Friend friend : friendsFriend) {
			if (friend.getId().getUser().getId() != userId) {
				friendsUser.add(friend.getId().getUser());
			}
			else {
				friendsUser.add(friend.getId().getFriend());
			}
		}
		return friendsUser;
	}

	/**
	 * Retrieves a list of User entities based on a search term and a specified filter.
	 * @param userId The ID of the user. Used to exclude that user and all of its friends
	 * from the result.
	 * @param searchTerm The search term used to filter user names.
	 * @param filter A filter that is used to filter based on a category.
	 * @return A list of User objects that match the search criteria and filter.
	 */
	@Override
	public List<User> getUsersByNameAndFilter(Long userId, String searchTerm, SearchFilter filter) {
		List<User> users = userRepository.findUsersByName(searchTerm);
		users.removeIf(user -> user.getId().equals(userId));
		List<User> friends = new ArrayList<>();
		switch (filter) {
			case NON_FRIENDS:
				friends = getFriends(userId);
				users.removeAll(friends);
				break;
			case FRIENDS:
				friends = getFriends(userId);
				Iterator<User> iterator = users.iterator();
				while (iterator.hasNext()) {
					User user = iterator.next();
					if (!friends.contains(user)) {
						iterator.remove();
					}
				}
				break;
		}
		return users;
	}

	/**
	 * Retrieves a list of randomly selected {@link User} objects based on the specified
	 * filter.
	 * @param userId The ID of the user. Used to exclude that user and all of its friends
	 * from the result depending on filter.
	 * @param amount The number of random users to retrieve.
	 * @param filter A filter that is used to filter based on a category.
	 * @return A list of randomly selected {@link User} objects.
	 */
	@Override
	public List<User> getRandomUsers(Long userId, int amount, SearchFilter filter) {
		List<User> users = userRepository.findAll();
		users.removeIf(user -> user.getId().equals(userId));
		switch (filter) {
			case NON_FRIENDS:
				List<User> friends = getFriends(userId);
				users.removeAll(friends);
				break;
		}

		Collections.shuffle(users);
		while (users.size() > amount) {
			users.remove(users.get(users.size() - 1));
		}
		return users;
	}

	/**
	 * Updates the subscription level of a specified user.
	 * @param userId The ID of the user whose subscription level is to be updated.
	 * @param subscriptionLevel The new SubscriptionLevel to assign to the user.
	 */
	@Override
	public void updateSubscriptionLevel(Long userId, SubscriptionLevel subscriptionLevel) {
		userRepository.updateSubscriptionLevel(userId, subscriptionLevel);
	}

	/**
	 * Sends feedback from an email.
	 * @param email The email.
	 * @param message The message.
	 */
	@Override
	public void sendFeedback(String email, String message) {
		Feedback feedback = Feedback.builder()
			.email(email)
			.message(message)
			.createdAt(Timestamp.from(Instant.now()))
			.build();
		feedbackRepository.save(feedback);
	}

	/**
	 * Get all feedback.
	 * @return A list containing all feedback.
	 */
	@Override
	public List<Feedback> getFeedback() {
		return feedbackRepository.findAll();
	}

	/**
	 * Check if the user has more than or equal to amount of current points as points
	 * @param user the user
	 * @param points the amount of points to compare with
	 * @return true or false
	 */
	@Override
	public Boolean hasMorePoints(User user, int points) {
		return user.getPoint().getCurrentPoints() >= points;
	}

	/**
	 * Deduct a number of current points from the user
	 * @param userId The user
	 * @param points The amount of current points to deduct
	 */
	@Override
	public void deductPoints(Long userId, int points) {
		userRepository.deductPoints(userId, points);
	}

}