/*
 * 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.service;

import java.time.LocalDateTime;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.ReentrantLock;
import java.util.stream.Collectors;

import javax.transaction.Transactional;

import nl.tudelft.labracore.api.AssignmentControllerApi;
import nl.tudelft.labracore.api.ModuleControllerApi;
import nl.tudelft.labracore.api.QuestionControllerApi;
import nl.tudelft.labracore.api.StudentGroupControllerApi;
import nl.tudelft.labracore.api.dto.*;
import nl.tudelft.labracore.lib.security.user.Person;
import nl.tudelft.queue.cache.*;
import nl.tudelft.queue.dto.create.RequestCreateDTO;
import nl.tudelft.queue.dto.util.ReportDTO;
import nl.tudelft.queue.dto.util.RequestTableFilterDTO;
import nl.tudelft.queue.model.ClosableTimeSlot;
import nl.tudelft.queue.model.LabRequest;
import nl.tudelft.queue.model.Request;
import nl.tudelft.queue.model.SelectionRequest;
import nl.tudelft.queue.model.enums.OnlineMode;
import nl.tudelft.queue.model.enums.RequestStatus;
import nl.tudelft.queue.model.enums.SelectionProcedure;
import nl.tudelft.queue.model.events.*;
import nl.tudelft.queue.model.labs.AbstractSlottedLab;
import nl.tudelft.queue.model.labs.ExamLab;
import nl.tudelft.queue.model.labs.Lab;
import nl.tudelft.queue.model.labs.SlottedLab;
import nl.tudelft.queue.repository.LabRequestRepository;
import nl.tudelft.queue.repository.ProfileRepository;
import nl.tudelft.queue.repository.RequestEventRepository;
import nl.tudelft.queue.repository.RequestRepository;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Service;
import org.springframework.ui.Model;

import reactor.core.publisher.Mono;

@Service
public class RequestService {

	@Autowired
	private RequestRepository rr;

	@Autowired
	private LabRequestRepository lrr;

	@Autowired
	private RequestEventRepository rer;

	@Autowired
	private ProfileRepository pr;

	@Autowired
	private QueuePushService qps;

	@Autowired
	private JitsiService js;

	@Autowired
	private WebSocketService wss;

	@Autowired
	private StudentGroupControllerApi sgApi;

	@Autowired
	private QuestionControllerApi qApi;

	@Autowired
	private AssignmentControllerApi asApi;

	@Autowired
	private RoleDTOService roleDTOService;

	@Autowired
	private PersonCacheManager pCache;

	@Autowired
	private RoomCacheManager rCache;

	@Autowired
	private SessionCacheManager sCache;

	@Autowired
	private ModuleControllerApi mApi;

	@Autowired
	private ModuleCacheManager mCache;

	@Autowired
	@Lazy
	private LabService lService;

	@Autowired
	private PermissionService permissionService;

	private static final ReentrantLock requestTakingLock = new ReentrantLock();
	private static final Set<Long> currentlyEnqueuing = ConcurrentHashMap.newKeySet();

	private EditionCacheManager eCache;

	@Autowired
	private CourseCacheManager cCache;

	@Autowired
	private ProgramCacheManager prCache;

	/**
	 * Creates a request in the request database based on the person that posted the request and the
	 * information the user posted in the form of a {@link RequestCreateDTO}.
	 *
	 * @param dto       The DTO containing user request information.
	 * @param personId  The id of the person that posted the request.
	 * @param sendEvent Whether to send an event to all users that the request was posted (mostly useful in
	 *                  development settings, where creation of a request is not http-request-rooted).
	 */
	@Transactional
	public void createRequest(
			RequestCreateDTO<?, ?> dto, Long personId, boolean sendEvent) {
		Request<?> request = dto.apply();
		request.setRequester(personId);
		request.setStudentGroup(getOrCreateIndividualStudentGroup(
				dto.getSession().getSession(), personId, dto.getModule()).getId());

		if (!request.getSession().allowsRequest(request)) {
			throw new AccessDeniedException("Request not allowed");
		}

		try {
			if (!currentlyEnqueuing.add(personId))
				return;
			if (request instanceof LabRequest labRequest) {

				if (labRequest.getTimeSlot() != null && !labRequest.getTimeSlot().canTakeSlot()) {
					throw new AccessDeniedException("Time slot is not available");
				}

				if (((LabRequest) request).getOnlineMode() == OnlineMode.JITSI) {
					labRequest.setJitsiRoom(js.createJitsiRoomName(labRequest));
				}
				if (((LabRequest) request).getSession().getEnableExperimental()) {
					((LabRequest) request).setQuestionId(qApi.addQuestion(new QuestionCreateDTO()
							.edition(new EditionIdDTO()
									.id(mCache.getRequired(dto.getModule()).getEdition().getId()))
							.question(((LabRequest) request).getQuestion())).block());
				}
			}

			request = rr.save(request);
			var event = rer.applyAndSave(new RequestCreatedEvent(request));

			if (request instanceof SelectionRequest) {
				((SelectionRequest) request).getSession().getRequests().add((SelectionRequest) request);
				checkFcfsSelection((SelectionRequest) request);
			}

			if (sendEvent) {
				wss.sendRequestCreated(event);
			}
		} finally {
			currentlyEnqueuing.remove(personId);
		}
	}

	/**
	 * Marks a request as taken through the publishing of a {@link RequestTakenEvent}.
	 *
	 * @param request   The request that is to be marked taken.
	 * @param assistant The assistant that took the request.
	 */
	@Transactional
	public void takeRequest(LabRequest request, Person assistant) {
		var event = rer.applyAndSave(new RequestTakenEvent(request, assistant.getId()));
		if (request.getEventInfo().getFirstProcessedAt() == null) {
			request.getEventInfo().setFirstProcessedAt(LocalDateTime.now());
			rr.save(request);
		}
		qps.notifyAssistantComing(request, assistant);
		wss.sendRequestTaken(event);
		wss.sendRequestPositionUpdate(request, request.getSession().getQueue());
	}

	/**
	 * Marks a request as forwarded to a specific person.
	 *
	 * @param request            The request to forward.
	 * @param assistant          The assistant that the request is forwarded to.
	 * @param reasonForAssistant The reason the request is forwarded given by the current assistant.
	 */
	@Transactional
	public void forwardRequestToPerson(LabRequest request, Person assistant, PersonSummaryDTO forwardedTo,
			String reasonForAssistant) {
		var event = rer.applyAndSave(new RequestForwardedToPersonEvent(
				request, assistant.getId(), forwardedTo.getId(), reasonForAssistant));
		qps.notifyRequestForwarded(request, assistant, forwardedTo);
		wss.sendRequestForwardedToPerson(event);
	}

	/**
	 * Marks a request as forwarded to no one in specific.
	 *
	 * @param request            The request that is to be forwarded.
	 * @param reasonForAssistant The reason the request is forwarded given by the current assistant.
	 */
	@Transactional
	public void forwardRequestToAnyone(LabRequest request, Person assistant, String reasonForAssistant) {
		var event = rer
				.applyAndSave(new RequestForwardedToAnyEvent(request, assistant.getId(), reasonForAssistant));
		qps.notifyRequestForwarded(request, assistant, null);
		wss.sendRequestForwardedToAny(event);
	}

	/**
	 * Approves a request by publishing a {@link RequestApprovedEvent}.
	 *
	 * @param request            The request that is to be approved.
	 * @param assistant          The assistant that approved the request.
	 * @param reasonForAssistant The (optional) reason the request is approved given by the current assistant.
	 */
	@Transactional
	public void approveRequest(LabRequest request, Long assistant, String reasonForAssistant, String answer) {
		var event = rer.applyAndSave(new RequestApprovedEvent(request, assistant, reasonForAssistant));
		if (answer != null && !answer.isBlank()) {
			qApi.patchQuestion(new QuestionPatchDTO().answer(answer), request.getQuestionId()).block();
		}
		wss.sendRequestHandled(request, event);
	}

	/**
	 * Rejects a request by publishing a {@link RequestRejectedEvent}.
	 *
	 * @param request            The request that is to be rejected.
	 * @param assistant          The assistant that rejected the request.
	 * @param reasonForAssistant The reason the request is rejected given by the current assistant as can be
	 *                           seen by other TAs.
	 * @param reasonForStudent   The reason the request is rejected as can be seen by the student.
	 */
	@Transactional
	public void rejectRequest(LabRequest request, Long assistant, String reasonForAssistant,
			String reasonForStudent) {
		var event = rer.applyAndSave(
				new RequestRejectedEvent(request, assistant, reasonForAssistant, reasonForStudent));
		wss.sendRequestHandled(request, event);
	}

	/**
	 * Rejects a selection request by publishing a {@Link SelectionRequestRejectedEvent}
	 *
	 * @param request            The selection request to be rejected.
	 * @param assistant          The assistant that rejected the request.
	 * @param reasonForAssistant The reson the request is rejected given by the current assistant as can be
	 *                           seen by other TAs.
	 * @param reasonForStudent   The reason the reqeust is rejected as can be seen by the student.
	 */
	@Transactional
	public void rejectSelectionRequest(SelectionRequest request, Long assistant,
			String reasonForAssistant, String reasonForStudent) {
		if (!request.getEventInfo().getStatus().isSelected()) {
			rer.applyAndSave(new SelectionRequestRejectedEvent(request, assistant,
					reasonForAssistant, reasonForStudent));
		} else {
			throw new AccessDeniedException("A user who has already been selected cannot be " +
					"rejected anymore.");
		}
	}

	/**
	 * Marks a request as 'not found' by publishing a {@link StudentNotFoundEvent}.
	 *
	 * @param request   The request that is to be marked.
	 * @param assistant The assistant that marks the request as 'not found'.
	 */
	@Transactional
	public void couldNotFindStudent(LabRequest request, Person assistant) {
		var event = rer.applyAndSave(new StudentNotFoundEvent(request, assistant.getId()));
		qps.notifyCouldNotFind(request, assistant);
		wss.sendRequestNotFound(event);
	}

	/**
	 * Revokes a request by publishing a {@link RequestRevokedEvent}.
	 *
	 * @param request The request that is to be revoked.
	 */
	@Transactional
	public void revokeRequest(Request<?> request) {
		var event = rer.applyAndSave(new RequestRevokedEvent(request));
		if (request instanceof SelectionRequest) {
			checkFcfsSelection((SelectionRequest) request);
		}
		wss.sendRequestRevoked(event);
	}

	/**
	 * Marks a request as "Not Picked" by publishing a {@link RequestNotPickedEvent}.
	 *
	 * @param request The request that was not picked.
	 */
	@Transactional
	public void notPickedRequest(LabRequest request) {
		var event = rer.applyAndSave(new RequestNotPickedEvent(request));
		wss.sendRequestNotPicked(event);
	}

	/**
	 * Marks a request as "Selected" by publishing a {@link RequestSelectedEvent}.
	 *
	 * @param request The request that was selected.
	 */
	@Transactional
	public void selectRequest(SelectionRequest request) {
		var event = rer.applyAndSave(new RequestSelectedEvent(request));
		wss.sendRequestSelected(event);
	}

	/**
	 * Marks a request as "Not selected" by publishing a {@link RequestNotSelectedEvent}.
	 *
	 * @param request The request that was not selected.
	 */
	@Transactional
	public void dontSelectRequest(SelectionRequest request) {
		var event = rer.applyAndSave(new RequestNotSelectedEvent(request));
		wss.sendRequestNotSelected(event);
	}

	/**
	 * Takes a next request from the given time slot for the given assistant. This method is central to the
	 * working of the Queue from the exam lab overview page. The order of finding a potential request from the
	 * time slot is defined in this method and should be changed here if need be. This method favours
	 * forwarded-to-person requests. After this, a pre-selected student request or a random request is picked.
	 *
	 * @param  assistant The assistant currently requesting a new request to handle.
	 * @param  timeSlot  The time slot for which the assistant wants to get a new request.
	 * @return           The request that was picked to handle next or nothing if none could be found.
	 */
	@Transactional(Transactional.TxType.REQUIRES_NEW)
	public Optional<LabRequest> takeNextRequestFromTimeSlot(Person assistant,
			ClosableTimeSlot timeSlot) {
		requestTakingLock.lock();
		try {

			// If the person is already working on a request, no new event should be created.
			Optional<LabRequest> request = lrr.findCurrentlyProcessingRequest(assistant, timeSlot);
			if (request.isPresent()) {
				return request;
			}

			// A new request should be found and assigned to the assistant.
			// First comes forwarded requests, then pre-selected student requests, then any request.
			ExamLab lab = (ExamLab) timeSlot.getLab();
			request = lrr.findCurrentlyForwardedRequest(assistant, timeSlot)
					.or(() -> pickRandomRequest(lab, lrr.findNextExamRequests(timeSlot, assistant)));

			request.ifPresent(r -> takeRequest(r, assistant));

			return request;
		} finally {
			requestTakingLock.unlock();
		}
	}

	/**
	 * Finds the next available request and marks it as taken as soon as one is found. This method is central
	 * to the working of Queue. The order of finding a potential next request determines priority and all
	 * subsequently called methods that query the Queue in {@link LabRequestRepository} determine the order in
	 * which requests are handled.
	 *
	 * @param  assistant The assistant currently requesting a new request to handle.
	 * @param  lab       The lab that the assistant is helping with, wanting a request for.
	 * @param  filter    The filter that the assistant is currently applying to the lab.
	 * @return           The request that was picked to handle next or nothing if none could be found.
	 */
	public Optional<LabRequest> takeNextRequest(Person assistant, Lab lab,
			RequestTableFilterDTO filter) {
		requestTakingLock.lock();
		try {
			return getNextRequest(assistant, lab, filter);
		} finally {
			requestTakingLock.unlock();
		}
	}

	@Transactional(Transactional.TxType.REQUIRES_NEW)
	public Optional<LabRequest> getNextRequest(Person assistant, Lab lab, RequestTableFilterDTO filter) {
		// If the person is already working on a request, no new event should be created.
		Optional<LabRequest> request = lrr.findCurrentlyProcessingRequest(assistant, lab);
		if (request.isPresent()) {
			return request;
		}

		// A new request should be found and assigned to the assistant.
		// First comes forwarded requests, then any other type of request.
		request = lrr.findCurrentlyForwardedRequest(assistant, lab)
				.or(() -> findNextRequest(lab, assistant, filter));

		request.ifPresent(r -> takeRequest(r, assistant));

		return request;
	}

	/**
	 * Assigns a specific request to a user. This however can only be done if the user has no processing
	 * requests open.
	 *
	 * @param  assistant The assistant currently requesting to be assigned to a request.
	 * @param  request   The request to which the assistant is assigned.
	 * @return           The request the assistant is currently assigned to.
	 */
	@Transactional(Transactional.TxType.REQUIRES_NEW)
	public LabRequest pickRequest(Person assistant, LabRequest request) {
		requestTakingLock.lock();
		try {
			Optional<LabRequest> oldRequests = lrr.findCurrentlyProcessingRequest(assistant,
					request.getSession());
			if (oldRequests.isPresent()) {
				return oldRequests.get();
			}
			rer.applyAndSave(new RequestPickedEvent(request, assistant.getId()));
			takeRequest(request, assistant);

			return request;
		} finally {
			requestTakingLock.unlock();
		}
	}

	/**
	 * Finds the next lab request. This could be a picked request, a timeslot request in a slotted lab or a
	 * normal request in any other lab. This method picks which one it should try to find based on the
	 * settings of the lab.
	 *
	 * @param  lab    The lab to find the next request for.
	 * @param  filter The filter that is currently applied to the requests table.
	 * @return        A fresh request for the assistant to take or an empty Optional if none is found.
	 */
	private Optional<LabRequest> findNextRequest(Lab lab, Person assistant, RequestTableFilterDTO filter) {
		var allowedAssignments = lService.getAllowedAssignmentsInLab(lab, assistant);
		var language = pr.findProfileForPerson(assistant).getLanguage();
		if (lab instanceof SlottedLab) {
			return lrr.findNextSlotRequest((AbstractSlottedLab<?>) lab, assistant, filter,
					allowedAssignments, language);
		} else if (lab instanceof ExamLab) {
			ExamLab examLab = (ExamLab) lab;
			return examLab.getTimeSlots().stream().filter(ClosableTimeSlot::getActive)
					.flatMap(ts -> pickRandomRequest(examLab, lrr.findNextExamRequests(ts, assistant))
							.stream())
					.findFirst();
		} else {
			return lrr.findNextNormalRequest(lab, assistant, filter, allowedAssignments, language);
		}
	}

	/**
	 * Picks a random request from a list of requests if a request can be picked. This method favours the
	 * picking of students that were pre-selected by teachers of the course.
	 *
	 * @param  lab      The lab that students were pre-picked in.
	 * @param  requests The request list to pick a request from.
	 * @return          The picked request or none if the list is empty.
	 */
	private Optional<LabRequest> pickRandomRequest(ExamLab lab, List<LabRequest> requests) {
		if (!lab.getPickedStudents().isEmpty() && requests.stream()
				.anyMatch(r -> lab.getPickedStudents().contains(r.getRequester()))) {
			requests = requests.stream()
					.filter(r -> lab.getPickedStudents().contains(r.getRequester()))
					.collect(Collectors.toList());
		}

		if (requests.isEmpty()) {
			return Optional.empty();
		}

		return requests.stream().skip(new Random().nextInt(requests.size())).findFirst();
	}

	/**
	 * Checks whether a limited capacity selection request may be selected early. Currently this only occurs
	 * for FCFS procedure sessions. This should usually occur after some event happened that changed the
	 * status of a request.
	 *
	 * @param request The request that the user revoked or a user recently placed.
	 */
	private void checkFcfsSelection(SelectionRequest request) {
		if (request.getSession().getCapacitySessionConfig().getProcedure() != SelectionProcedure.FCFS) {
			return;
		}

		var room = rCache.getRequired(request.getRoom());
		int openSpaces = request.getSession().openSpacesInRoom(room);

		request.getSession().getPendingRequestsInRoom(room.getId()).stream()
				.sorted(Comparator.comparing(Request::getCreatedAt))
				.limit(openSpaces)
				.forEach(this::selectRequest);
	}

	/**
	 * Gets or creates an individual student group for the requester.
	 *
	 * @param  sessionId The id of the session that the user needs a group for.
	 * @param  moduleId  The id of the module that the user is making a request for.
	 * @param  personId  The id of the person that posted the request.
	 * @return           The fetched or created student group for the given requester.
	 */
	public StudentGroupDetailsDTO getOrCreateIndividualStudentGroup(Long sessionId, Long personId,
			Long moduleId) {
		var group = sgApi.getGroupForPersonAndModule(personId, moduleId).onErrorResume(e -> Mono.empty())
				.blockOptional();
		return group.orElseGet(() -> {
			var emptyGroup = sgApi.getAllGroupsInModule(moduleId)
					.filter(g -> g.getMemberUsernames().isEmpty()).blockFirst();
			if (emptyGroup != null) {
				sgApi.addMemberToGroup(emptyGroup.getId(), personId).block();
				return sgApi.getStudentGroupsById(List.of(emptyGroup.getId())).blockFirst();
			} else {
				return createIndividualStudentGroup(sessionId, personId, moduleId);
			}
		});
	}

	/**
	 * This is meant to act as a filter for a list of lab requests that originates from a shared session. This
	 * is so that assistants from other editions don't see requests from other editions they are not an
	 * assistant of.
	 *
	 * @param  requests A list of requests to filter
	 * @return          A new list of lab requests that the assistant will see only if they are an assistant
	 *                  of the edition the request belongs.
	 */
	public List<LabRequest> filterRequestsSharedEditionCheck(List<LabRequest> requests) {
		// Check to make sure requests are not empty, otherwise API call fails.
		if (requests.isEmpty()) {
			return requests;
		}

		var assignmentIds = requests.stream().map(LabRequest::getAssignment).distinct().toList();

		var allowedAssignments = Objects
				.requireNonNull(asApi.getAssignmentsWithModules(assignmentIds).collectList().block()).stream()
				.filter(assignment -> permissionService
						.hasStaffRole(List.of(assignment.getModule().getEdition().getId())))
				.map(AssignmentModuleDetailsDTO::getId)
				.collect(Collectors.toSet());

		return requests.stream().filter(r -> allowedAssignments.contains(r.getAssignment())).toList();
	}

	/**
	 * Setup all the required people and names for the report page.
	 *
	 * @param labRequest  The request for which this report page is being generated.
	 * @param assistantId The id of the person to be reported.
	 * @param model       The model which needs to filled in.
	 */
	public void setupReportInformation(LabRequest labRequest, Long assistantId, Model model) {
		String name = pCache.get(assistantId).orElseThrow().getDisplayName();
		model.addAttribute("report", ReportDTO.builder().assistantName(name).build());
		Long editionId = sCache.getOrThrow(labRequest.getSession().getId()).getEdition().getId();
		Long courseId = eCache.getOrThrow(editionId).getCourse().getId();
		CourseDetailsDTO course = cCache.getOrThrow(courseId);
		model.addAttribute("teacher", course.getManagers());
		Long programId = course.getProgram().getId();
		ProgramDetailsDTO program = prCache.getOrThrow(programId);
		model.addAttribute("director", program.getDirector());
		model.addAttribute("counsellor", program.getAcademicCounsellor());
		model.addAttribute("associationTrustee", program.getStudyAssociationTrustee());
	}

	/**
	 * Creates an individual student group for one student without a current group so that they may place a
	 * request with that student group.
	 *
	 * @param  sessionId The id of the session that the user needs a group for.
	 * @param  moduleId  The id of the module that the user is making a request for.
	 * @param  personId  The id of the person that posted the request.
	 * @return           The created student group.
	 */
	private StudentGroupDetailsDTO createIndividualStudentGroup(Long sessionId, Long personId,
			Long moduleId) {
		var person = pCache.getRequired(personId);
		var module = mApi.getModuleById(moduleId).block();
		var edition = module.getEdition();

		return sgApi.addGroup(new StudentGroupCreateDTO()
				.name(person.getDisplayName())
				.capacity(1)
				.addMembersItem(new RoleIdDTO().id(new Id()
						.personId(person.getId())
						.editionId(edition.getId())))
				.module(new ModuleIdDTO().id(moduleId)))
				.flatMap(id -> sgApi.getStudentGroupsById(List.of(id)).collectList().map(l -> l.get(0)))
				.block();
	}

	/**
	 * Distributes requests by abusing the forwarding mechanism. Assigns requests in a round-robin fashion.
	 *
	 * @param selectedAssignments Assignments that should be distributed
	 * @param selectedAssistants  Assistants that should receieve the distributed requests
	 * @param distributor         Person who is responsible for distributing the requests
	 * @param lab                 The lab session that the requests belong to.
	 */
	@Transactional
	public void distributeRequests(List<Long> selectedAssignments, List<Long> selectedAssistants,
			Person distributor, Lab lab) {
		var assistants = pCache
				.getAndIgnoreMissing(selectedAssistants);

		if (assistants.isEmpty())
			return;

		var requests = lab.getRequests().stream()
				.filter(rq -> selectedAssignments.contains(rq.getAssignment())
						&& rq.getEventInfo().getStatus() == RequestStatus.PENDING)
				.sorted(Comparator.comparing(Request::getCreatedAt))
				.toList();

		// round robin request assignment
		for (int i = 0; i < requests.size(); i++) {
			forwardRequestToPerson(requests.get(i), distributor, assistants.get(i % assistants.size()),
					"Distributed by " + distributor.getDisplayName());
		}
	}
}
