/*
 * Queue - A Queueing system that can be used to handle labs in higher education
 * Copyright (C) 2016-2024  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.function.Function;
import java.util.stream.Collectors;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;

import jakarta.transaction.Transactional;
import nl.tudelft.labracore.api.SessionControllerApi;
import nl.tudelft.labracore.api.StudentGroupControllerApi;
import nl.tudelft.labracore.api.dto.RolePersonLayer1DTO;
import nl.tudelft.labracore.api.dto.RoomDetailsDTO;
import nl.tudelft.labracore.api.dto.SessionDetailsDTO;
import nl.tudelft.queue.cache.PersonCacheManager;
import nl.tudelft.queue.cache.SessionCacheManager;
import nl.tudelft.queue.cache.StudentGroupCacheManager;
import nl.tudelft.queue.model.Request;
import nl.tudelft.queue.model.SelectionRequest;
import nl.tudelft.queue.model.embeddables.RequestEventInfo;
import nl.tudelft.queue.model.labs.CapacitySession;
import nl.tudelft.queue.repository.CapacitySessionRepository;

@Service
public class CapacitySessionService {
	private static final Logger logger = LoggerFactory.getLogger(CapacitySessionService.class);

	@Autowired
	private CapacitySessionRepository csr;

	@Autowired
	private RequestService rs;

	@Autowired
	private SessionControllerApi sApi;

	@Autowired
	private StudentGroupControllerApi sgApi;

	@Autowired
	private PersonCacheManager pCache;

	@Autowired
	@Lazy
	private LabService ls;

	@Autowired
	private SessionService sessionService;

	private SessionCacheManager sCache;

	private StudentGroupCacheManager sgCache;

	/**
	 * Counts the number of members in a student group that are students in that group.
	 *
	 * @param  sgId The id of the student group to count members in.
	 * @return      The number of students in the group.
	 */
	private int countStudentMembers(Long sgId) {
		return (int) sgCache.getRequired(sgId).getMembers().stream()
				.filter(role -> role.getType() == RolePersonLayer1DTO.TypeEnum.STUDENT)
				.count();
	}

	/**
	 * Gets all relevant capacity lab requests from previous labs. This method relates previous capacity labs
	 * that used one of the modules that are selected for this lab. The output is a mapping of student group
	 * IDs to the requests that were previously selected for them.
	 *
	 * @param  qSession The lab to get previous labs for.
	 * @return          The mapping of student group ids to their previous requests.
	 */
	private Map<Long, List<SelectionRequest>> getRelevantPreviousCapacityLabRequests(
			CapacitySession qSession) {
		// Create map of student group ids to their previously selected requests.
		Map<Long, List<SelectionRequest>> previousRequests = qSession.getRequests().stream()
				.map(SelectionRequest::getStudentGroup).distinct()
				.collect(Collectors.toMap(Function.identity(), r -> new ArrayList<>()));

		// Find all the past capacity labs with any of the labs modules in use and add requests from each of these labs
		// to the previousRequests map. The requests added are selected requests only.
		csr.findAllPastLabsByModules(qSession.getModules()).stream()
				.flatMap(l -> l.getRequests().stream())
				.filter(request -> request.getEventInfo().getStatus().isSelected())
				.forEach(request -> {
					previousRequests.putIfAbsent(request.getStudentGroup(), new ArrayList<>());
					previousRequests.get(request.getStudentGroup()).add(request);
				});

		return previousRequests;
	}

	/**
	 * Selects the requests sorted by the given comparator first and then randomly for each equal request.
	 *
	 * @param  requests       The requests to select from.
	 * @param  amountToSelect The number of requests to select.
	 * @param  comparator     The comparator defining which requests have priority.
	 * @return                The list of selected requests.
	 */
	private List<SelectionRequest> selectRequestsSortedThenRandom(List<SelectionRequest> requests,
			int amountToSelect,
			Comparator<SelectionRequest> comparator) {
		List<SelectionRequest> shuffled = new ArrayList<>(requests);
		Collections.shuffle(shuffled);

		return selectAmount(requests.stream()
				.sorted(comparator.thenComparing(shuffled::indexOf))
				.collect(Collectors.toList()), amountToSelect);
	}

	/**
	 * Selects a number of requests such that the total number of members of the student groups do not exceed
	 * the given amount to select.
	 *
	 * @param  requests       The requests to select from.
	 * @param  amountToSelect The number of requests to select.
	 * @return                The list of selected requests.
	 */
	private List<SelectionRequest> selectAmount(List<SelectionRequest> requests, int amountToSelect) {
		int count = 0;
		for (SelectionRequest request : requests) {
			if (amountToSelect <= 0) {
				break;
			}

			amountToSelect -= countStudentMembers(request.getStudentGroup());
			count++;
		}

		return requests.stream().limit(count).collect(Collectors.toList());
	}

	/**
	 * Selects requests completely randomly.
	 *
	 * @param  requests       The requests to select from.
	 * @param  amountToSelect The number of requests to select.
	 * @return                The list of selected requests.
	 */
	private List<SelectionRequest> selectRequestsAtRandom(List<SelectionRequest> requests,
			int amountToSelect) {
		List<SelectionRequest> shuffled = new ArrayList<>(requests);
		Collections.shuffle(shuffled);

		return selectAmount(shuffled, amountToSelect);
	}

	/**
	 * Selects requests by giving priority to those groups that have attended as little as possible previous
	 * sessions.
	 *
	 * @param  qSession       The lab to select the requests for.
	 * @param  requests       The requests to select from.
	 * @param  amountToSelect The number of requests to select.
	 * @return                The list of selected requests.
	 */
	private List<SelectionRequest> selectRequestsSortedByPreviouslySelectedCount(CapacitySession qSession,
			List<SelectionRequest> requests,
			int amountToSelect) {
		var previousRequests = getRelevantPreviousCapacityLabRequests(qSession).entrySet().stream()
				.collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().size()));

		return selectRequestsSortedThenRandom(requests, amountToSelect,
				Comparator.comparing(request -> previousRequests.get(request.getStudentGroup())));
	}

	/**
	 * Selects requests by giving priority to those groups that were least recently selected.
	 *
	 * @param  qSession       The lab to select the requests for.
	 * @param  requests       The requests to select from.
	 * @param  amountToSelect The number of requests to select.
	 * @return                The list of selected requests.
	 */
	private List<SelectionRequest> selectRequestsSortedByLeastRecentlySelected(CapacitySession qSession,
			List<SelectionRequest> requests,
			int amountToSelect) {
		// Create a mapping of student group ids to the time of the lab of their latest selected request.
		Map<Long, LocalDateTime> previousRequests = getRelevantPreviousCapacityLabRequests(qSession)
				.entrySet()
				.stream()
				.collect(Collectors.toMap(
						Map.Entry::getKey,
						e -> e.getValue().stream()
								.flatMap(request -> sCache.get(request.getSession().getSession())
										.map(SessionDetailsDTO::getEndTime)
										.stream())
								.max(Comparator.naturalOrder())
								.orElse(LocalDateTime.MIN)));

		// Select the requests that have the earliest latest selected requests and randomly after that.
		return selectRequestsSortedThenRandom(requests, amountToSelect,
				Comparator.comparing(request -> previousRequests.get(request.getStudentGroup())));
	}

	/**
	 * Selects n requests from the given list of requests for the given lab.
	 *
	 * @param  qSession       The lab to select the requests for.
	 * @param  requests       The requests to select from.
	 * @param  amountToSelect The number of requests to select.
	 * @return                The list of selected requests.
	 */
	private List<SelectionRequest> getSelectedRequests(CapacitySession qSession,
			List<SelectionRequest> requests, int amountToSelect) {
		switch (qSession.getCapacitySessionConfig().getProcedure()) {
			case FCFS:
				logger.warn("First come first serve condition reached, this should not be the case.");
				logger.warn("This condition was reached for the session with id: {}.", qSession.getId());

				return List.of();
			case RANDOM:
				return selectRequestsAtRandom(requests, amountToSelect);
			case WEIGHTED_BY_COUNT:
				return selectRequestsSortedByPreviouslySelectedCount(qSession, requests, amountToSelect);
			case WEIGHTED_BY_LEAST_RECENT:
				return selectRequestsSortedByLeastRecentlySelected(qSession, requests, amountToSelect);
		}
		return List.of();
	}

	/**
	 * Calculates the amount of requests to select in the lab.
	 *
	 * @param  lab                     The lab to select students in.
	 * @param  alreadySelectedRequests The requests that were already selected.
	 * @return                         The number of people that should be selected.
	 */
	private int getAmountToSelect(CapacitySession lab, List<SelectionRequest> alreadySelectedRequests) {
		var session = sessionService.getCoreSession(lab.getSession());

		// Count the number of requests that have already been selected in case someone gets manually selected.
		int countAlreadySelected = (int) alreadySelectedRequests.stream()
				.map(SelectionRequest::getStudentGroup)
				.mapToLong(this::countStudentMembers)
				.sum();

		// Calculate the amount selected from room capacity.
		int amountToSelect = session.getRooms().stream()
				.mapToInt(room -> Optional.ofNullable(room.getCapacity()).orElse(0)).sum();

		// Make sure to only select up to the allowed amount.
		amountToSelect = Math.max(0, amountToSelect - countAlreadySelected);

		return amountToSelect;
	}

	/**
	 * Divides the given list of requests that were selected to come over the rooms that are available for the
	 * lab automatically. Student groups are assigned to a room in the order in which they were selected until
	 * a room is full.
	 *
	 * @param qSession         The lab the selected requests are a part of.
	 * @param selectedRequests The requests that were selected.
	 */
	@Transactional
	public void divideSelectedRequests(CapacitySession qSession, List<SelectionRequest> selectedRequests) {
		var session = sessionService.getCoreSession(qSession.getSession());

		// For every room in the session, initialize a count
		Map<RoomDetailsDTO, Integer> occupancy = new HashMap<>();
		List<RoomDetailsDTO> rooms = session.getRooms().stream().toList();
		for (var room : rooms) {
			occupancy.put(room, 0);
		}

		// Keep track of a current room and current room index.
		int roomIndex = 0;
		RoomDetailsDTO currentRoom = rooms.get(roomIndex);

		// For every selected request, in order, set the room and change the state of the request.
		for (SelectionRequest request : selectedRequests) {
			int memberCount = countStudentMembers(request.getStudentGroup());
			int capacity = Optional.ofNullable(currentRoom.getCapacity()).orElse(0);

			// If the current room is filled at or over capacity, start filling the next room.
			if (roomIndex != rooms.size() - 1 &&
					occupancy.get(currentRoom) >= capacity) {
				currentRoom = rooms.get(++roomIndex);
			}

			// Set the room for the request and update the room occupancy.
			request.setRoom(currentRoom.getId());
			occupancy.put(currentRoom, occupancy.get(currentRoom) + memberCount);

			// For requests that are not yet selected, add the selected event.
			if (!request.getEventInfo().getStatus().isSelected()) {
				rs.selectRequest(request);
			}
		}

		// Finally, for every request, if they were not selected, add this to the request events.
		for (SelectionRequest request : qSession.getRequests()) {
			if (request.getEventInfo().getStatus().isPending()) {
				rs.dontSelectRequest(request);
			}
		}
	}

	/**
	 * Initializes all caches that are used when running a selection. Pre-fetches all data required for a lab
	 * so that no more fetched need to be done during the algorithm.
	 *
	 * @param labs The labs that selections will be run over.
	 */
	@Transactional
	public void initializeCaches(List<CapacitySession> labs) {
		sCache = new SessionCacheManager(sApi);
		sgCache = new StudentGroupCacheManager(sgApi, pCache);

		sessionService.getCoreSessions(labs.stream().map(CapacitySession::getSession).toList());
		sgCache.getAndIgnoreMissing(labs.stream().flatMap(lab -> lab.getRequests().stream())
				.map(SelectionRequest::getStudentGroup));
	}

	/**
	 * Runs every minute on the 3rd second of the minute. This method finds all capacity-labs that have to
	 * have their requests picked. For each of these labs, it selects students up to the capacity of the rooms
	 * that are available in the lab.
	 */
	@Transactional
	@Scheduled(cron = "3 * * * * ?")
	public void selectStudentsInCapacityLab() {
		// Find the labs that selection should be done for.
		List<CapacitySession> labs = csr.findAllThatShouldPickStudents();
		initializeCaches(labs);

		// Run the selection algorithm for all labs
		for (CapacitySession qSession : labs) {
			// Find the requests that are pending and the requests that somehow already have a selected status.
			List<SelectionRequest> requestsToSelectFrom = qSession.getRequests().stream()
					.filter(request -> request.getEventInfo().getStatus().isPending())
					.collect(Collectors.toList());
			List<SelectionRequest> alreadySelectedRequests = qSession.getRequests().stream()
					.filter(request -> request.getEventInfo().getStatus().isSelected())
					.collect(Collectors.toList());

			// Calculate the total (leftover) capacity of the lab.
			int amountToSelect = getAmountToSelect(qSession, alreadySelectedRequests);

			// Get subset of the requests that will be selected to come.
			List<SelectionRequest> selectedRequests = getSelectedRequests(qSession, requestsToSelectFrom,
					amountToSelect);
			selectedRequests.addAll(alreadySelectedRequests);

			// Divide the selected requests over the rooms and update the status of each of the requests.
			divideSelectedRequests(qSession, selectedRequests);
		}
	}

	/**
	 * Resets all the requests for a {@link CapacitySession}. It reverts any potentially picked requests to
	 * pending. If the session is a FCFS session, new requests will be selected depending on their creation
	 * time and date. For any other type of capacity session, it will wait for the selection date to expire on
	 * which selection will happen automatically.
	 *
	 * @param session The session for which the requests should be reset.
	 */
	@Transactional
	public void resetRequests(CapacitySession session) {
		// Reset all requests
		for (SelectionRequest req : session.getRequests()) {
			req.setEventInfo(new RequestEventInfo());
		}
		// If we have a fcfs lab we should select new students immediatly.
		if (session.getCapacitySessionConfig().getProcedure().isFcfs()) {
			for (RoomDetailsDTO room : sessionService.getCoreSession(session.getSession()).getRooms()) {
				session.getRequests().stream()
						.filter(selectionRequest -> !selectionRequest.getEventInfo().getStatus().isSelected())
						.peek((r) -> r.setRoom(room.getId()))
						.sorted(Comparator.comparing(Request::getCreatedAt))
						.limit(room.getCapacity())
						.forEachOrdered(rs::selectRequest);
			}
		}
	}
}
