/*
 * Queue - A Queueing system that can be used to handle labs in higher education
 * Copyright (C) 2016-2021  Delft University of Technology
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as
 * published by the Free Software Foundation, either version 3 of the
 * License, or (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */
package nl.tudelft.queue.repository;

import static com.querydsl.jpa.JPAExpressions.select;
import static java.time.LocalDateTime.now;

import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;

import nl.tudelft.labracore.api.dto.AssignmentSummaryDTO;
import nl.tudelft.labracore.lib.security.user.Person;
import nl.tudelft.queue.dto.util.RequestTableFilterDTO;
import nl.tudelft.queue.model.*;
import nl.tudelft.queue.model.enums.Language;
import nl.tudelft.queue.model.enums.QueueSessionType;
import nl.tudelft.queue.model.enums.RequestStatus;
import nl.tudelft.queue.model.labs.AbstractSlottedLab;
import nl.tudelft.queue.model.labs.Lab;

import org.springframework.data.domain.*;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.querydsl.QuerydslPredicateExecutor;
import org.springframework.lang.NonNull;

import com.querydsl.core.types.Predicate;
import com.querydsl.core.types.dsl.BooleanExpression;

public interface LabRequestRepository
		extends JpaRepository<LabRequest, Long>, QuerydslPredicateExecutor<LabRequest> {
	QLabRequest qlr = QLabRequest.labRequest;

	@NonNull
	@Override
	List<LabRequest> findAll(@NonNull Predicate predicate);

	@NonNull
	@Override
	List<LabRequest> findAll(@NonNull Predicate predicate, @NonNull Sort sort);

	/**
	 * Finds all requests that are part of the given lab.
	 *
	 * @param  lab The lab to get the requests from.
	 * @return     The list of requests in the given lab.
	 */
	default List<LabRequest> findAllByLab(Lab lab) {
		return findAll(qlr.session.id.eq(lab.getId()));
	}

	/**
	 * Counts the previous requests from a list of groups after applying a filter.
	 *
	 * @param  groups The groups to search for in made requests
	 * @param  filter The filter to apply to the list of found requestz
	 * @return        The amount of found requests
	 */
	default long countPreviousRequests(List<Long> groups, RequestTableFilterDTO filter) {
		return count(createFilterBooleanExpression(filter, qlr.studentGroup.in(groups)));
	}

	/**
	 * Finds a page of previous requests from the given list of groups after applying the given filter.
	 *
	 * @param  groups   The groups to search for in made requests.
	 * @param  filter   The filter to apply to the list of found requests.
	 * @param  pageable The pageable containing configurations for displaying the page.
	 * @return          The page of previous requests.
	 */
	default Page<LabRequest> findAllPreviousRequests(List<Long> groups, RequestTableFilterDTO filter,
			Pageable pageable) {
		return findAll(createFilterBooleanExpression(filter, qlr.studentGroup.in(groups)), pageable);
	}

	/**
	 * Finds all previous requests that have to do with the same assignment as the given request.
	 *
	 * @param  request The request to find historically similar requests for.
	 * @return         The sorted list of all previous related requests.
	 */
	default List<LabRequest> findAllPreviousRequests(LabRequest request) {
		return findAll(qlr.assignment.eq(request.getAssignment())
				.and(qlr.createdAt.before(request.getCreatedAt()))
				.and(qlr.studentGroup.eq(request.getStudentGroup())),
				Sort.sort(LabRequest.class).by(LabRequest::getCreatedAt).ascending());
	}

	/**
	 * Finds the request that is currently being processed by the given assistant in the given lab if any
	 * exists.
	 *
	 * @param  assistant The assistant for which to look up the request.
	 * @param  lab       The lab in which to find the request.
	 * @return           Either the currently processing request or nothing.
	 */
	default Optional<LabRequest> findCurrentlyProcessingRequest(Person assistant, Lab lab) {
		return findOne(qlr.session.id.eq(lab.getId())
				.and(qlr.eventInfo.status.eq(RequestStatus.PROCESSING))
				.and(qlr.eventInfo.assignedTo.eq(assistant.getId())));
	}

	/**
	 * Finds the request that is currently being processed by the given assistant in the given slot if any
	 * exists.
	 *
	 * @param  assistant The assistant for which to look up the request.
	 * @param  slot      The slot in which to find the request.
	 * @return           Either the currently processing request or nothing.
	 */
	default Optional<LabRequest> findCurrentlyProcessingRequest(Person assistant, TimeSlot slot) {
		return findOne(qlr.timeSlot.id.eq(slot.getId())
				.and(qlr.eventInfo.status.eq(RequestStatus.PROCESSING))
				.and(qlr.eventInfo.assignedTo.eq(assistant.getId())));
	}

	/**
	 * Finds the first request that is currently specifically forwarded to the given assistant within the
	 * given lab. This is useful for determining what request should be handled next.
	 *
	 * @param  assistant The assistant that the request should be forwarded to.
	 * @param  lab       The lab in which the request exists.
	 * @return           The forwarded request or none if no such request exists.
	 */
	default Optional<LabRequest> findCurrentlyForwardedRequest(Person assistant, Lab lab) {
		return findAll(qlr.session.id.eq(lab.getId())
				.and(qlr.eventInfo.status.eq(RequestStatus.FORWARDED))
				.and(qlr.eventInfo.forwardedTo.eq(assistant.getId())),
				Sort.sort(LabRequest.class).by((LabRequest r) -> r.getEventInfo().getLastEventAt())
						.ascending())
								.stream().findFirst();
	}

	/**
	 * Finds the first request that is currently specifically forwarded to the given assistant within the
	 * given time slot.
	 *
	 * @param  assistant The assistant that is getting a next request.
	 * @param  slot      The slot for which the assistant is getting the next request.
	 * @return           The found request or none if no such request exists.
	 */
	default Optional<LabRequest> findCurrentlyForwardedRequest(Person assistant, TimeSlot slot) {
		return findAll(qlr.timeSlot.id.eq(slot.getId())
				.and(qlr.eventInfo.status.eq(RequestStatus.FORWARDED))
				.and(qlr.eventInfo.forwardedTo.eq(assistant.getId())),
				Sort.sort(LabRequest.class).by((LabRequest r) -> r.getEventInfo().getLastEventAt())
						.ascending())
								.stream().findFirst();
	}

	/**
	 * Finds all next requests for the given exam time slot.
	 *
	 * @param  timeSlot  The time slot to get next requests from.
	 * @param  assistant The assistant that is getting the next request.
	 * @return           All requests that can be picked next in the given time slot.
	 */
	default List<LabRequest> findNextExamRequests(ClosableTimeSlot timeSlot, Person assistant) {
		return findAll(qlr.timeSlot.id.eq(timeSlot.getId())
				.and(isStatusOrForwardedToAny(RequestStatus.PENDING, assistant)));
	}

	/**
	 * Counts the number of requests the assistant can grab next for the given time slot.
	 *
	 * @param  timeSlot  The id of the time slot the assistant would get the next request from.
	 * @param  assistant The assistant that is fetching the next request.
	 * @return           The number of requests that can be picked from the time slot.
	 */
	default long countNextExamRequests(Long timeSlot, Person assistant) {
		return count(qlr.timeSlot.id.eq(timeSlot)
				.and(isStatusOrForwarded(RequestStatus.PENDING, assistant)));
	}

	/**
	 * Finds the next request within a timeslotted lab that has the PENDING or FORWARDED (to any) status. This
	 * request will be assigned to the assistant querying for a new request to handle. A timeslotted request
	 * can be taken if its slot is open or if its slot is 10 minutes from now.
	 *
	 * @param  lab                The timeslotted lab from which the next request will be taken.
	 * @param  assistant          The assistant that is getting the next request.
	 * @param  filter             The filter to apply to the request table query.
	 * @param  allowedAssignments The assignments which the assistant is allowed to get.
	 * @param  language           The assistant's language choice
	 * @return                    The next slotted request from the given lab.
	 */
	default Optional<LabRequest> findNextSlotRequest(AbstractSlottedLab<?> lab, Person assistant,
			RequestTableFilterDTO filter, List<AssignmentSummaryDTO> allowedAssignments, Language language) {
		List<Long> assignments = allowedAssignments.stream().map(AssignmentSummaryDTO::getId)
				.collect(Collectors.toList());
		return findAll(createFilterBooleanExpression(filter,
				qlr.session.id.eq(lab.getId())
						.and(qlr.assignment.in(assignments))
						.and(matchesLanguagePreference(language))
						.and(isStatusOrForwardedToAny(RequestStatus.PENDING, assistant))
						.and(qlr.timeSlot.slot.opensAt.before(now().plusMinutes(lab.getEarlyOpenTime())))),
				PageRequest.of(0, 1, Sort.sort(LabRequest.class)
						.by((LabRequest r) -> r.getTimeSlot().getSlot().getOpensAt())
						.ascending()))
								.stream().findFirst();
	}

	/**
	 * Counts the number of slotted requests currently open in the given lab from the viewpoint of the given
	 * Person applying the given filter.
	 *
	 * @param  lab                The lab to find the number of open slotted requests for.
	 * @param  assistant          The assistant requesting the number of open requests.
	 * @param  filter             The fitler the assistant is applying to their requests view.
	 * @param  allowedAssignments The assignments which the assistant is allowed to get.
	 * @param  language           The assistant's language choice
	 * @return                    The count of open slotted requests.
	 */
	default long countOpenSlotRequests(AbstractSlottedLab<?> lab, Person assistant,
			RequestTableFilterDTO filter, List<AssignmentSummaryDTO> allowedAssignments, Language language) {
		var assignments = allowedAssignments.stream().map(AssignmentSummaryDTO::getId).toList();
		return count(createFilterBooleanExpression(filter,
				qlr.session.id.eq(lab.getId())
						.and(qlr.assignment.in(assignments))
						.and(matchesLanguagePreference(language))
						.and(isStatusOrForwarded(RequestStatus.PENDING, assistant))
						.and(qlr.timeSlot.slot.opensAt.before(now().plusMinutes(lab.getEarlyOpenTime())))));
	}

	/**
	 * Finds the next request within a regular lab that has the PENDING or FORWARDED (to any) status. This
	 * request will be assigned to the assistant querying for a new request to handle.
	 *
	 * @param  lab                The lab from which the next request will be taken.
	 * @param  assistant          The assistant that is getting the next request.
	 * @param  filter             The filter to apply to the request table query.
	 * @param  allowedAssignments The assignments which the assistant is allowed to get.
	 * @param  language           The assistant's language choice
	 * @return                    The next request from the given lab.
	 */
	default Optional<LabRequest> findNextNormalRequest(Lab lab, Person assistant,
			RequestTableFilterDTO filter,
			List<AssignmentSummaryDTO> allowedAssignments,
			Language language) {
		List<Long> assignments = allowedAssignments.stream().map(AssignmentSummaryDTO::getId)
				.collect(Collectors.toList());
		return findAll(createFilterBooleanExpression(filter,
				qlr.session.id.eq(lab.getId())
						.and(isStatusOrForwardedToAny(RequestStatus.PENDING, assistant))
						.and(matchesLanguagePreference(language))
						.and(qlr.assignment.in(assignments))),
				PageRequest.of(0, 1, Sort.sort(LabRequest.class).by(LabRequest::getCreatedAt).ascending()))
						.stream().findFirst();
	}

	/**
	 * Counts the number of open regular requests in the given lab from the viewpoint of the given assistant
	 * with the given filter applied.
	 *
	 * @param  lab                The lab to count the number of open requests in.
	 * @param  assistant          The assistant requesting this count.
	 * @param  filter             The filter applied by the assistant on the page they are requesting.
	 * @param  allowedAssignments The assignments which the assistant is allowed to get.
	 * @param  language           The assistant's language choice
	 * @return                    The number of regular requests in the given lab.
	 */
	default long countNormalRequests(Lab lab, Person assistant, RequestTableFilterDTO filter,
			List<AssignmentSummaryDTO> allowedAssignments, Language language) {
		var assignemnts = allowedAssignments.stream().map(AssignmentSummaryDTO::getId).toList();
		return count(createFilterBooleanExpression(filter,
				qlr.session.id.eq(lab.getId())
						.and(qlr.assignment.in(assignemnts))
						.and(matchesLanguagePreference(language))
						.and(isStatusOrForwarded(RequestStatus.PENDING, assistant))));
	}

	/**
	 * Checks whether the request matches a given language preference. Bilingual requests match every
	 * preference. English only and dutch only match their respective preferences as well as the bilingual
	 * preference.
	 *
	 * @param  preference The language preference
	 * @return            The predicate for matching the language preference
	 */
	default BooleanExpression matchesLanguagePreference(Language preference) {
		return switch (preference) {
			case ANY -> qlr.language.isNotNull(); // Always true but Expressions.TRUE does not work
			case DUTCH_ONLY -> qlr.language.eq(Language.ANY).or(qlr.language.eq(Language.DUTCH_ONLY));
			case ENGLISH_ONLY -> qlr.language.eq(Language.ANY)
					.or(qlr.language.eq(Language.ENGLISH_ONLY));
		};
	}

	/**
	 * Creates a {@link BooleanExpression} for querying the request table. This expression checks whether the
	 * request either has the given status or the forwarded status with no specific person it is forwarded to.
	 *
	 * @param  status The status that the request is desired to have.
	 * @return        The predicate that represents this expression.
	 */
	default BooleanExpression isStatusOrForwardedToAny(RequestStatus status, Person assistant) {
		return qlr.eventInfo.status.eq(status)
				.or(qlr.eventInfo.status.eq(RequestStatus.FORWARDED)
						.and(qlr.eventInfo.forwardedTo.isNull())
						.and(qlr.eventInfo.forwardedBy.ne(assistant.getId())));
	}

	/**
	 * Creates a {@link BooleanExpression} for querying the request table. This expressiopn check whether the
	 * request either has the given status or the forwarded status with a specific person or the forwarded
	 * status with no specific person it is forwarded to.
	 *
	 * @param  status    The status that the request is desired to have.
	 * @param  assistant The assistant to find forwarded requests for.
	 * @return           The predicate expression representing this condition.
	 */
	default BooleanExpression isStatusOrForwarded(RequestStatus status, Person assistant) {
		return qlr.eventInfo.status.eq(status)
				.or(qlr.eventInfo.status.eq(RequestStatus.FORWARDED)
						.andAnyOf(qlr.eventInfo.forwardedTo.isNull()
								.and(qlr.eventInfo.forwardedBy.ne(assistant.getId())),
								qlr.eventInfo.forwardedTo.eq(assistant.getId())));
	}

	/**
	 * Counts all requests that pass the given filter. This filter is a DTO transferred from the page
	 * requesting to see these requests. The filter contains all labs, rooms, assignments, etc. that need to
	 * be displayed. If a field in the filter is left as an empty set, the filter ignores it.
	 *
	 * @param  labs   The labs that the should also be kept in the filter.
	 * @param  filter The filter to apply to the boolean expression.
	 * @return        The amount of requests.
	 */
	default long countByFilter(List<Lab> labs, RequestTableFilterDTO filter) {
		return count(qlr.in(select(qlr).from(qlr)
				.leftJoin(qlr.timeSlot, QTimeSlot.timeSlot).on(qlr.timeSlot.id.eq(QTimeSlot.timeSlot.id))
				.where(createFilterBooleanExpression(filter, qlr.session.in(labs).and(
						qlr.session.type.eq(QueueSessionType.REGULAR)
								.or(qlr.session.type.in(QueueSessionType.SLOTTED, QueueSessionType.EXAM)
										.and(qlr.timeSlot.slot.opensAt.before(now().plusMinutes(10)))))))));
	}

	/**
	 * Finds all requests that pass the given filter. This filter is a DTO transferred from the page
	 * requesting to see these requests. The filter contains all labs, rooms, assignments, etc. that need to
	 * be displayed. If a field in the filter is left as an empty set, the filter ignores it. Additionally,
	 * returns only past and upcoming requests.
	 *
	 * @param  labs     The labs that the should also be kept in the filter.
	 * @param  filter   The filter to apply to the boolean expression.
	 * @param  language The assistant's language choice
	 * @return          The filtered list of requests.
	 */
	default List<LabRequest> findAllByFilterUpcoming(List<Lab> labs, RequestTableFilterDTO filter,
			Language language) {
		return findAll(qlr.in(select(qlr).from(qlr)
				.leftJoin(qlr.timeSlot, QTimeSlot.timeSlot).on(qlr.timeSlot.id.eq(QTimeSlot.timeSlot.id))
				.where(createFilterBooleanExpression(filter, qlr.session.in(labs).and(
						qlr.session.type.eq(QueueSessionType.REGULAR)
								.or(qlr.session.type.in(QueueSessionType.SLOTTED, QueueSessionType.EXAM)
										.and(qlr.timeSlot.slot.opensAt.before(now())))))
												.and(matchesLanguagePreference(language)))));
	}

	/**
	 * Finds all requests that pass the given filter. This filter is a DTO transferred from the page
	 * requesting to see these requests. The filter contains all labs, rooms, assignments, etc. that need to
	 * be displayed. If a field in the filter is left as an empty set, the filter ignores it.
	 *
	 * @param  labs     The labs that the should also be kept in the filter.
	 * @param  filter   The filter to apply to the boolean expression.
	 * @param  language The assistant's language choice
	 * @return          The filtered list of requests.
	 */
	default List<LabRequest> findAllByFilter(List<Lab> labs, RequestTableFilterDTO filter,
			Language language) {
		return findAll(qlr.in(select(qlr).from(qlr)
				.leftJoin(qlr.timeSlot, QTimeSlot.timeSlot).on(qlr.timeSlot.id.eq(QTimeSlot.timeSlot.id))
				.where(createFilterBooleanExpression(filter, qlr.session.in(labs))
						.and(matchesLanguagePreference(language)))));
	}

	/**
	 * Creates the boolean expression that will filter requests through a filter DTO. This filter is a DTO
	 * transferred from the page requesting to see these requests. The filter contains all labs, rooms,
	 * assignments, etc. that need to be displayed. If a field in the filter is left as an empty set, the
	 * filter ignores it.
	 *
	 * @param  filter The filter to apply to the boolean expression.
	 * @param  e      The boolean expression upon which the filter is applied.
	 * @return        The combined boolean expression of the original expression and the filter applied to it.
	 */
	default BooleanExpression createFilterBooleanExpression(RequestTableFilterDTO filter,
			BooleanExpression e) {
		e = and(e, filter.getAssigned(), qlr.eventInfo.assignedTo::in);
		e = and(e, filter.getLabs(), qlr.session.id::in);
		e = and(e, filter.getRooms(), qlr.room::in);
		e = and(e, filter.getOnlineModes(), qlr.onlineMode::in);
		e = and(e, filter.getAssignments(), qlr.assignment::in);
		e = and(e, filter.getRequestStatuses(), qlr.eventInfo.status::in);
		e = and(e, filter.getRequestTypes(), qlr.requestType::in);

		return e;
	}

	/**
	 * Performs a conjunction between the two boolean expressions only if the given filter set is non-empty.
	 * If the filter set is empty, the original boolean expression is returned.
	 *
	 * @param  e   The boolean expression to build on.
	 * @param  t   The set of filter types.
	 * @param  f   The function that will create a new boolean expression from the filter types.
	 * @param  <T> The type of the filter types (usually a Long).
	 * @return     The combined boolean expression.
	 */
	default <T> BooleanExpression and(BooleanExpression e, Set<T> t,
			Function<Set<T>, BooleanExpression> f) {
		if (t.isEmpty()) {
			return e;
		}
		return e.and(f.apply(t));
	}
}
