SearchService.java

package com.nonononoki.alovoa.service;

import com.nonononoki.alovoa.Tools;
import com.nonononoki.alovoa.component.TextEncryptorConverter;
import com.nonononoki.alovoa.entity.User;
import com.nonononoki.alovoa.entity.user.Gender;
import com.nonononoki.alovoa.entity.user.UserIntention;
import com.nonononoki.alovoa.model.AlovoaException;
import com.nonononoki.alovoa.model.SearchDto;
import com.nonononoki.alovoa.model.SearchDto.SearchStage;
import com.nonononoki.alovoa.model.UserDto;
import com.nonononoki.alovoa.model.UserSearchRequest;
import com.nonononoki.alovoa.repo.GenderRepository;
import com.nonononoki.alovoa.repo.UserRepository;
import lombok.Builder;
import lombok.Getter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Service;
import uk.recurse.geocoding.reverse.Country;
import uk.recurse.geocoding.reverse.ReverseGeocoder;

import javax.crypto.BadPaddingException;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
import java.io.UnsupportedEncodingException;
import java.security.InvalidAlgorithmParameterException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.*;
import java.util.stream.Collectors;

@Service
public class SearchService {

    public static final int SORT_DISTANCE = 1;
    public static final int SORT_ACTIVE_DATE = 2;
    public static final int SORT_INTEREST = 3;
    public static final int SORT_DEFAULT = 4;
    public static final int SORT_DONATION_TOTAL = 5;
    public static final int SORT_NEWEST_USER = 6;
    private static final double LATITUDE = 111.1;
    private static final double LONGITUDE = 111.320;
    private static final int DEFAULT_DISTANCE = 50;
    private static final int SEARCH_MAX = 200;
    private static ReverseGeocoder geocoder = null;

    private static final Set<Long> ALL_INTENTIONS = Set.of(UserIntention.MEET, UserIntention.DATE, UserIntention.SEX);
    private static final Set<Long> ALL_GENDER_IDS = Set.of(Gender.MALE, Gender.FEMALE, Gender.OTHER);

    @Autowired
    private TextEncryptorConverter textEncryptor;
    @Autowired
    private AuthService authService;
    @Autowired
    private UserRepository userRepo;
    @Autowired
    private PublicService publicService;
    @Autowired
    private UserService userService;
    @Autowired
    private GenderRepository genderRepo;
    @Value("${app.search.max}")
    private int maxResults;
    @Value("${app.search.max.distance}")
    private int maxDistance;
    @Value("${app.age.min}")
    private int ageMin;
    @Value("${app.age.max}")
    private int ageMax;
    @Value("${app.search.estimate.max}")
    private int searchEstimateMax;
    @Value("${app.search.ignore-intention}")
    private boolean ignoreIntention;

    public ReverseGeocoder getGeocoder() {
        if (geocoder == null) {
            geocoder = new ReverseGeocoder();
        }
        return geocoder;
    }

    @Deprecated
    public SearchDto searchDefault() throws AlovoaException, InvalidKeyException, IllegalBlockSizeException,
            BadPaddingException, NoSuchAlgorithmException, NoSuchPaddingException, InvalidAlgorithmParameterException,
            UnsupportedEncodingException {
        User user = authService.getCurrentUser(true);
        if (user.isAdmin()) {
            return SearchDto.builder().users(searchResultsToUserDto(userRepo.adminSearch(), 0, user)).build();
        }
        if (user.getLocationLatitude() != null) {
            return searchComplete(SearchParams.builder().latitude(user.getLocationLatitude())
                    .longitude(user.getLocationLongitude()).build());
        } else {
            return null;
        }
    }

    @Deprecated
    public SearchDto search(Double latitude, Double longitude, int distance, int sortId) throws AlovoaException,
            InvalidKeyException, IllegalBlockSizeException, BadPaddingException, NoSuchAlgorithmException,
            NoSuchPaddingException, InvalidAlgorithmParameterException, UnsupportedEncodingException {
        return searchComplete(SearchParams.builder().latitude(latitude)
                .longitude(longitude).distance(distance).sort(sortId).build());
    }

    public SearchDto searchComplete(SearchParams params) throws AlovoaException,
            InvalidKeyException, IllegalBlockSizeException, BadPaddingException, NoSuchAlgorithmException,
            NoSuchPaddingException, InvalidAlgorithmParameterException, UnsupportedEncodingException {

        User user = authService.getCurrentUser(true);
        if (user.isAdmin()) {
            return SearchDto.builder().users(searchResultsToUserDto(userRepo.adminSearch(), 0, user)).build();
        }

        Double latitude = params.getLatitude();
        Double longitude = params.getLongitude();

        int distance = params.getDistance();
        int sortId = params.getSort();
        boolean showOutsideParameters = params.isShowOutsideParameters();
        Set<Long> intentions = params.getIntentions().isEmpty() ? ALL_INTENTIONS : params.getIntentions();
        Set<String> interests = params.getInterests();
        Set<Integer> miscInfoIds = params.getMiscInfos();

        Set<Long> preferredGenderIds;
        Set<Long> userPreferredGenderIds =  user.getPreferedGenders().stream().map(Gender::getId).collect(Collectors.toSet());
        boolean updatedPreferredGenderIds = !params.getPreferredGenderIds().isEmpty() && !Objects.equals(userPreferredGenderIds, params.getPreferredGenderIds());
        if(updatedPreferredGenderIds)  {
            user.setPreferedGenders(new HashSet<>(genderRepo.findAllById(params.getPreferredGenderIds())));
            preferredGenderIds = params.getPreferredGenderIds();
        } else {
            preferredGenderIds = userPreferredGenderIds;
        }

        if (!latitude.equals(user.getLocationLatitude()) || !longitude.equals(user.getLocationLongitude()) || user.getCountry() == null) {
            Optional<String> country = getCountryIsoByLocation(latitude, longitude);
            if (country.isPresent()) {
                userService.updateCountry(country.get());
            }
        }

        Sort sort = switch (sortId) {
            case SORT_DEFAULT -> Sort.by(Sort.Direction.DESC, "dates.latestDonationDate", "dates.creationDate");
            case SORT_ACTIVE_DATE -> Sort.by(Sort.Direction.DESC, "dates.activeDate");
            case SORT_DONATION_TOTAL -> Sort.by(Sort.Direction.DESC, "totalDonations");
            case SORT_NEWEST_USER -> Sort.by(Sort.Direction.DESC, "dates.creationDate");
            default -> Sort.unsorted();
        };

        int ageLegal = Tools.AGE_LEGAL;

        if (distance > maxDistance) {
            throw new AlovoaException("max_distance_exceeded");
        }

        user.getDates().setActiveDate(new Date());

        userService.updateUserLocation(latitude, longitude);

        int age = Tools.calcUserAge(user);
        boolean isLegalAge = age >= ageLegal;
        int minAge = params.getPreferredMinAge() == null ? user.getPreferedMinAge() : params.getPreferredMinAge();
        int maxAge = params.getPreferredMaxAge() == null ? user.getPreferedMaxAge() : params.getPreferredMaxAge();

        user.setPreferedMinAge(minAge);
        user.setPreferedMaxAge(maxAge);

        if (isLegalAge && minAge < ageLegal) {
            minAge = ageLegal;
        }
        if (!isLegalAge && maxAge >= ageLegal) {
            maxAge = ageLegal - 1;
        }

        Date minDate = Tools.ageToDate(maxAge);
        Date maxDate = Tools.ageToDate(minAge);

        double deltaLongFactor = LONGITUDE * Math.cos(latitude / 180.0 * Math.PI);
        double deltaLat = distance / LATITUDE;
        double deltaLong = distance / deltaLongFactor;
        double minLat = latitude - deltaLat;
        double maxLat = latitude + deltaLat;
        double minLong = longitude - deltaLong;
        double maxLong = longitude + deltaLong;

        userRepo.saveAndFlush(user);

        UserSearchRequest request = UserSearchRequest.builder().age(age).minLat(minLat).minLong(minLong).maxLat(maxLat)
                .maxLong(maxLong).maxDateDob(maxDate).minDateDob(minDate).intentionIds(intentions).preferedGender(user.getGender())
                .likeIds(user.getLikes().stream().map(o -> o.getUserTo() != null ? o.getUserTo().getId() : 0).collect(Collectors.toSet()))
                .blockIds(user.getBlockedUsers().stream().map(o -> o.getUserTo() != null ? o.getUserTo().getId() : 0).collect(Collectors.toSet()))
                .hideIds(user.getHiddenUsers().stream().map(o -> o.getUserTo() != null ? o.getUserTo().getId() : 0).collect(Collectors.toSet()))
                .genderIds(preferredGenderIds)
                .blockedByIds(user.getBlockedByUsers().stream().map(o -> o.getUserFrom() != null ? o.getUserFrom().getId() : 0).collect(Collectors.toSet()))
                .miscInfos(miscInfoIds)
                .interests(interests)
                .build();

        request.getBlockIds().add(user.getId());

        List<User> users;
        if(request.getMiscInfos().isEmpty() && request.getInterests().isEmpty()) {
            users = userRepo.usersSearchNoExtras(request, PageRequest.of(0, SEARCH_MAX, sort));
        } else if(!request.getMiscInfos().isEmpty() && request.getInterests().isEmpty()) {
            users = userRepo.usersSearchMisc(request, PageRequest.of(0, SEARCH_MAX, sort));
        } else if(request.getMiscInfos().isEmpty()) {
            users = userRepo.usersSearchInterest(request, PageRequest.of(0, SEARCH_MAX, sort));
        } else {
            users = userRepo.usersSearchInterestMisc(request, PageRequest.of(0, SEARCH_MAX, sort));
        }

        if (!users.isEmpty()) {
            return SearchDto.builder().users(searchResultsToUserDto(users, sortId, user))
                    .stage(SearchStage.NORMAL).build();
        }

        // NO COMPATIBLE USERS FOUND
        if(showOutsideParameters) {

            //SEARCH COMPATIBLE USERS
            request.setIntentionIds(ALL_INTENTIONS);
            request.setMiscInfos(Set.of());
            users = userRepo.usersBaseSearch(request, PageRequest.of(0, SEARCH_MAX, sort));
            if (!users.isEmpty()) {
                return SearchDto.builder().users(searchResultsToUserDto(users, sortId, user))
                        .global(false).build();
            }

            // NO COMPATIBLE USERS FOUND NEARBY, SEARCH AROUND THE WORLD!
            users = userRepo.usersSearchAllIgnoreLocation(request, PageRequest.of(0, SEARCH_MAX, sort));
                return SearchDto.builder().users(searchResultsToUserDto(users, sortId, user))
                        .global(true).incompatible(true).stage(SearchStage.WORLD).build();
        } else {
            return SearchDto.builder().users(List.of()).build();
        }
    }

    private List<UserDto> searchResultsToUserDto(final List<User> userList, int sort, User user)
            throws InvalidKeyException, IllegalBlockSizeException, BadPaddingException, NoSuchAlgorithmException,
            NoSuchPaddingException, InvalidAlgorithmParameterException, UnsupportedEncodingException, AlovoaException {
        List<UserDto> userDtos = new ArrayList<>();
        for (User u : userList) {
            UserDto dto = UserDto.userToUserDto(UserDto.DtoBuilder.builder().ignoreIntention(ignoreIntention)
                    .currentUser(user).user(u).userService(userService).build());
            userDtos.add(dto);
        }

        if (sort == SORT_DISTANCE) {
            userDtos = userDtos.stream().sorted(Comparator.comparing(UserDto::getDistanceToUser))
                    .collect(Collectors.toList());
        } else if (sort == SORT_INTEREST) {
            Comparator<UserDto> comparatorCommonInterest = Comparator.comparing(f -> f.getCommonInterests().size());
            userDtos = userDtos.stream().filter(f -> !f.getCommonInterests().isEmpty())
                    .sorted(comparatorCommonInterest.reversed()
                            .thenComparing(Comparator.comparing(UserDto::getDistanceToUser).reversed()))
                    .collect(Collectors.toList());
        }

        return userDtos;
    }

    public Optional<String> getCountryIsoByLocation(double lat, double lon) {
        Optional<Country> country = getGeocoder().getCountry(lat, lon);
        return country.map(Country::iso);
    }

    @Getter
    @Builder
    public static class SearchParams {
        @Builder.Default
        private Set<Long> preferredGenderIds = new HashSet<>();
        @Builder.Default
        private Integer preferredMinAge = null;
        @Builder.Default
        private Integer preferredMaxAge = null;
        @Builder.Default
        private int distance = DEFAULT_DISTANCE;
        @Builder.Default
        private boolean showOutsideParameters = true;
        @Builder.Default
        private int sort = SORT_DEFAULT;
        private double latitude;
        private double longitude;
        @Builder.Default
        private Set<Integer> miscInfos = new HashSet<>();
        @Builder.Default
        private Set<Long> intentions = new HashSet<>();
        @Builder.Default
        private Set<String> interests = new HashSet<>();
    }

}