/*
 * 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 static java.time.temporal.ChronoField.SECOND_OF_DAY;

import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import jakarta.transaction.Transactional;
import nl.tudelft.labracore.api.dto.AssignmentDetailsDTO;
import nl.tudelft.labracore.api.dto.PersonSummaryDTO;
import nl.tudelft.labracore.api.dto.RoomDetailsDTO;
import nl.tudelft.queue.cache.AssignmentCacheManager;
import nl.tudelft.queue.cache.EditionCacheManager;
import nl.tudelft.queue.cache.ModuleCacheManager;
import nl.tudelft.queue.cache.PersonCacheManager;
import nl.tudelft.queue.cache.RoomCacheManager;
import nl.tudelft.queue.cache.SessionCacheManager;
import nl.tudelft.queue.dto.view.statistics.AssignmentCountStatisticsViewDto;
import nl.tudelft.queue.dto.view.statistics.AssistantRatingViewDto;
import nl.tudelft.queue.dto.view.statistics.LabStatisticsViewDto;
import nl.tudelft.queue.model.LabRequest;
import nl.tudelft.queue.model.Request;
import nl.tudelft.queue.model.enums.RequestType;
import nl.tudelft.queue.model.labs.Lab;
import nl.tudelft.queue.model.records.RequestCountRecord;
import nl.tudelft.queue.repository.LabRequestRepository;

@Service
public class EditionStatusService {

	@Autowired
	private RoomCacheManager rCache;

	@Autowired
	private LabRequestRepository rr;

	@Autowired
	private EditionCacheManager eCache;

	@Autowired
	private ModuleCacheManager mCache;

	@Autowired
	private AssignmentCacheManager aCache;

	@Autowired
	private SessionCacheManager sCache;

	@Autowired
	private PersonCacheManager pCache;

	@Autowired
	private FeedbackService fs;
	@Autowired
	private SessionService sessionService;

	/**
	 * Creates the buckets used for counting frequencies of events occurring. Buckets are based on the minimum
	 * time of day a lab in the given list of labs starts and the maximum time of day such a lab ends. The
	 * returned buckets are represented as intervals in the following way: Each entry in the returned tree set
	 * represents the start of an interval except the last entry. Each of the n intervals labeled 1 through n
	 * is defined by [buckets[n-1], buckets[n]).
	 *
	 * Note: this function fails to give sensible buckets if the slot for a lab somehow passes the dateline.
	 * That is, if a lab is from 22:00 to 02:00 in the timezone of the server, the server will return buckets
	 * for the times 02:00 to 22:00, essentially displaying nothing.
	 *
	 * @param  labs     The labs that intervals will be calculated over.
	 * @param  nBuckets The number of buckets to create.
	 * @return          A treeset of interval start times.
	 */
	@Transactional
	public TreeSet<Long> createBucketsOverCourse(List<Lab> labs,
			int nBuckets) {
		var sessions = sessionService.getCoreSessions(labs.stream().map(Lab::getSession).toList());

		long min = sessions.stream()
				.map(l -> l.getStart().getLong(SECOND_OF_DAY))
				.min(Comparator.comparingLong(t -> t))
				.orElse(0L);

		long max = sessions.stream()
				.map(l -> l.getEndTime().getLong(SECOND_OF_DAY))
				.min(Comparator.comparingLong(t -> t))
				.orElse(1L);

		long bucketSize = (long) Math.ceil((double) (max - min) / nBuckets);
		return Stream.iterate(min, t -> t + bucketSize)
				.limit(nBuckets + 1)
				.collect(Collectors.toCollection(TreeSet::new));
	}

	/**
	 * Counts the number of requests that are created during each bucket interval.
	 *
	 * @param  buckets  The intervals over which to count frequencies.
	 * @param  requests The requests to count.
	 * @return          A list of frequencies where each i-th entry corresponds to the i-th interval.
	 */
	public List<Long> countCreatedRequestsInBuckets(TreeSet<Long> buckets, List<LabRequest> requests) {
		return countInBucketsAsList(buckets, requests.stream()
				.map(r -> r.getCreatedAt().getLong(SECOND_OF_DAY))
				.collect(Collectors.toList()));
	}

	/**
	 * Counts the number of requests that are handled during each bucket interval.
	 *
	 * @param  buckets  The intervals over which to count frequencies.
	 * @param  requests The requests to count.
	 * @return          A list of frequencies where each i-th entry corresponds to the i-th interval.
	 */
	public List<Long> countHandledRequestsInBuckets(TreeSet<Long> buckets, List<LabRequest> requests) {
		return countInBucketsAsList(buckets, requests.stream()
				.filter(r -> r.getEventInfo().getStatus().isHandled()
						&& r.getEventInfo().getHandledAt() != null)
				.map(r -> r.getEventInfo().getHandledAt().getLong(SECOND_OF_DAY))
				.collect(Collectors.toList()));
	}

	/**
	 * Counts the number of entries of list l for which predicate p holds.
	 *
	 * @param  l   The list to count over.
	 * @param  p   The predicate which entries in l are to be held to.
	 * @param  <T> The type of the entries in list l.
	 * @return     The number of entries in list l for which p holds.
	 */
	public <T> Long countWhere(List<T> l, Predicate<T> p) {
		return l.stream().filter(p).count();
	}

	/**
	 * Gets the requests that are part of any lab in the list of labs, any assignment in the list of
	 * assignments, and any room in the list of rooms.
	 *
	 * @param  labs        The list of labs the lab of the request should be in.
	 * @param  assignments The list of assignment the request's assignment should be in.
	 * @param  rooms       The list of rooms the request's room should be in.
	 * @return             The list of requests filtered based on the given labs, assignments, and rooms.
	 */
	public List<LabRequest> getFilteredRequests(
			List<Lab> labs,
			List<Long> assignments,
			List<Long> rooms,
			List<RequestType> requestTypes) {
		return rr.findAllByFilters(labs, assignments, rooms, requestTypes);
	}

	/**
	 * Gets the requests that are part of any lab in the list of labs.
	 *
	 * @param  labs The list of labs the request's lab should be in.
	 * @return      The list of requests filtered based on the given labs.
	 */
	public List<LabRequest> getFilteredRequests(List<Lab> labs) {
		return rr.findAllBySessionIdIn(labs.stream().map(Lab::getId).toList());
	}

	/**
	 * Counts the number of requests that are/were open during each bucket interval.
	 *
	 * @param  buckets  The intervals over which to count frequencies.
	 * @param  requests The requests to count.
	 * @return          A list of frequencies where each i-th entry corresponds to the i-th interval.
	 */
	public List<Long> countOpenRequestsInBuckets(TreeSet<Long> buckets, List<LabRequest> requests) {
		return buckets.stream()
				.sorted()
				.limit(buckets.size() - 1)
				.map(bucket -> {
					final long nextBucket = buckets.higher(bucket);
					return requests.stream()
							.filter(r -> r.getCreatedAt().getLong(SECOND_OF_DAY) <= nextBucket &&
									(!r.getEventInfo().getStatus().isFinished() ||
											(r.getEventInfo().getHandledAt() != null &&
													nextBucket < r.getEventInfo().getHandledAt()
															.getLong(SECOND_OF_DAY))))
							.count();
				})
				.collect(Collectors.toList());
	}

	/**
	 * Maps the assignments and counts the question and submission frequency of each assignments among a
	 * collection of requests.
	 *
	 * @param  requests    The requests to consider
	 * @param  assignments The assignments you want to count the question and submission frequencies of
	 * @return             A list of DTOs which capture this information for each assignment
	 */
	public List<AssignmentCountStatisticsViewDto> countAssignmentFreqs(List<LabRequest> requests,
			List<AssignmentDetailsDTO> assignments) {
		return assignments.stream()
				.sorted(Comparator.comparing(AssignmentDetailsDTO::getName))
				.map(assignment -> {
					var assignmentName = assignment.getName();
					var assignmentQuestionCount = requests.stream()
							.filter(rq -> Objects.equals(rq.getAssignment(), assignment.getId())
									&& rq.getRequestType() == RequestType.QUESTION)
							.count();
					var assignmentSubmissionCount = requests.stream()
							.filter(rq -> Objects.equals(rq.getAssignment(), assignment.getId())
									&& rq.getRequestType() == RequestType.SUBMISSION)
							.count();

					return new AssignmentCountStatisticsViewDto(assignment.getId(), assignmentName,
							new RequestCountRecord(assignmentQuestionCount, assignmentSubmissionCount));
				}).toList();
	}

	/**
	 * Creates a mapping of the given list of rooms to the number of times each of the rooms occurs within the
	 * given list of requests.
	 *
	 * @param  rooms    The rooms to count for.
	 * @param  requests The requests to count.
	 * @return          The mapping from room names to the number of times each of the corresponding rooms
	 *                  occurs in the list of requests.
	 */
	public Map<String, Long> countRequestsPerRoom(List<Long> rooms, List<LabRequest> requests) {
		return rCache.getAndIgnoreMissing(rooms).stream()
				.distinct()
				.collect(Collectors.toMap(
						RoomDetailsDTO::getName,
						room -> countWhere(requests, req -> req.getRoom().equals(room.getId()))));
	}

	/**
	 * Finds the key with the highest corresponding value.
	 *
	 * @param  counts A map of counts mapping names to the number of times the name occurs.
	 * @return        The name with the highest count or "N.A." if none was found.
	 */
	public String mostCountedName(Map<String, Long> counts) {
		return counts.entrySet().stream()
				.max(Comparator.comparingLong(Map.Entry::getValue))
				.map(Map.Entry::getKey)
				.orElse("N.A.");
	}

	/**
	 * Counts the number of distinct request entities within the given list of requests.
	 *
	 * @param  requests The filtered list of requests for which to count.
	 * @return          The number of distinct request entities.
	 */
	public Long countDistinctUsers(List<LabRequest> requests) {
		return requests.stream()
				.map(Request::getStudentGroup)
				.distinct()
				.count();
	}

	/**
	 * Counts the number of distinct assistants within the given list of requests.
	 *
	 * @param  requests The filtered list of requests for which to count.
	 * @return          The number of distinct assistants.
	 */
	public Long countDistinctAssistants(List<LabRequest> requests) {
		return requests.stream()
				.filter(r -> r.getEventInfo().getAssignedTo() != null)
				.map(r -> r.getEventInfo().getAssignedTo())
				.distinct()
				.count();
	}

	/**
	 * Combines the average rating per assistant and their number of requests within the given list of
	 * requests.
	 *
	 * @param  requests The list of requests for which to count.
	 * @return          A List of DTOs holding the necessary information for every assistant for the assistant
	 *                  table.
	 */
	public List<AssistantRatingViewDto> ratingAndRequestsForAssistant(List<LabRequest> requests) {

		List<Long> ids = requests.stream()
				.map(r -> r.getEventInfo().getAssignedTo())
				.distinct()
				.filter(Objects::nonNull)
				.toList();

		final Map<Long, Long> requestsPerAssistant = countRequestsPerAssistant(requests);

		// Populate cache
		var people = pCache.getAndIgnoreMissing(ids);

		return people.stream().map(PersonSummaryDTO::getId).map(pID -> new AssistantRatingViewDto(
				pCache.getRequired(pID),
				requestsPerAssistant.getOrDefault(pID, 0L),
				fs.getAvgStarRating(pID, requests))).toList();
	}

	/**
	 * Counts the number of requests per assistant within the given list of requests.
	 *
	 * @param  requests The list of requests for which to count.
	 * @return          The mapping from assistant id to the number of assigned to them so far.
	 */
	public Map<Long, Long> countRequestsPerAssistant(List<LabRequest> requests) {
		return requests.stream()
				.map(r -> r.getEventInfo().getAssignedTo())
				.filter(Objects::nonNull)
				.collect(Collectors.groupingBy(Function.identity(), Collectors.counting()));
	}

	/**
	 * See {@link #averageWaitingTime(List, ChronoUnit, LocalDateTime)} for complete description.
	 *
	 * @param  requests The lists of requests to consider
	 * @return          The average waiting time for these requests
	 */
	public Long averageWaitingTime(List<LabRequest> requests) {
		return averageWaitingTime(requests, ChronoUnit.MILLIS, LocalDateTime.now());
	}

	/**
	 * See {@link #averageProcessingTime(List, ChronoUnit, LocalDateTime)} for complete description.
	 *
	 * @param  requests The lists of requests to consider
	 * @return          The average processing time for these requests
	 */
	public Long averageProcessingTime(List<LabRequest> requests) {
		return averageProcessingTime(requests, ChronoUnit.MILLIS, LocalDateTime.now());
	}

	/**
	 * Calculates the average waiting time over all requests that are being processed or are already
	 * processed.
	 *
	 * Note: for timeslots, we take the start of the timeslot as the start of the waiting period, whereas if
	 * there is no timeslot, we take the createdAt time.
	 *
	 * @param  requests      The list of requests to calculate over.
	 * @param  unit          The unit to calculate the average time into.
	 * @param  referenceTime For requests that have not been processed yet, we still want to calculate the
	 *                       waiting time. Furthermore, sometimes we want to calculate how long the average
	 *                       time was in the perspective of an earlier time instead of now.
	 * @return               The average time between creating a request and a TA starting to process it.
	 */
	public Long averageWaitingTime(List<LabRequest> requests, ChronoUnit unit, LocalDateTime referenceTime) {
		return (long) requests
				.stream()
				.mapToLong(r -> {
					LocalDateTime startTime = (r.getTimeSlot() != null)
							? r.getTimeSlot().getSlot().getOpensAt()
							: r.getCreatedAt();

					if (referenceTime.isBefore(startTime)) {
						return 0L;
					}

					return unit.between(startTime,
							min(r.getEventInfo().getFirstProcessedAt(), referenceTime));
				})
				.average().orElse(0.0);
	}

	/**
	 * Calculates the average processing time (the time between a TA starting to process the student and the
	 * request being handled) over the following types of requests
	 *
	 * <li>Requests that have been handled</li>
	 * <li>Requests that are currently being handled (The time until now is taken in this case).</li>
	 *
	 * @param  requests      The list of requests to calculate over.
	 * @param  unit          The unit to calculate the average time into.
	 * @param  referenceTime For requests that have not been handled yet, we still want to calculate the
	 *                       average processing time. Furthermore, sometimes we want to calculate the average
	 *                       time from the perspective of an earlier time.
	 * @return               The average time between processing a request and it being handled.
	 */
	public Long averageProcessingTime(List<LabRequest> requests, ChronoUnit unit,
			LocalDateTime referenceTime) {

		return (long) requests.stream()
				.filter(r -> r.getEventInfo().getFirstProcessedAt() != null)
				.mapToLong(r -> {
					final LocalDateTime handledAt = r.getEventInfo().getHandledAt();
					final LocalDateTime firstProcessedAt = r.getEventInfo().getFirstProcessedAt();

					if (referenceTime.isBefore(firstProcessedAt)) {
						return 0L;
					}

					return unit.between(firstProcessedAt, min(handledAt, referenceTime));
				})
				.average()
				.orElse(0.0);
	}

	/**
	 * Counts the frequencies of a list of longs over the given intervals into a list.
	 *
	 * @param  buckets The intervals over which to count frequencies.
	 * @param  data    The data to count.
	 * @return         A list of frequencies where each i-th entry corresponds to the i-th interval.
	 */
	private List<Long> countInBucketsAsList(TreeSet<Long> buckets, List<Long> data) {
		Map<Long, Long> frequencies = data.stream()
				.collect(Collectors.groupingBy(
						time -> {
							// Find the bucket the time belongs to or the first if it falls out of bounds
							Long bucket = buckets.floor(time);
							if (bucket == null) {
								bucket = buckets.first();
							}
							return bucket;
						},
						Collectors.reducing(0L, (a, t) -> a + 1)));

		return buckets.stream()
				.sorted()
				.limit(buckets.size() - 1)
				.map(bucket -> frequencies.getOrDefault(bucket, 0L))
				.collect(Collectors.toList());
	}

	/**
	 * Groups requests into their associated lab along with how many question and submission requests were in
	 * that lab.
	 *
	 * @param  requests The list of requests to consider
	 * @return          The mapping as described above
	 */
	public Map<String, RequestCountRecord> countRequestsPerLab(List<LabRequest> requests) {

		Map<String, List<LabRequest>> groupedLabRequests = groupRequestsByLab(requests);

		return new TreeMap<>(groupedLabRequests.entrySet().stream()
				.collect(Collectors.toMap(
						Map.Entry::getKey, // Use the lab name as the key.
						entry -> entry.getValue().stream().map(r -> r.getRequestType() == RequestType.QUESTION
								? new RequestCountRecord(1L, 0L)
								: new RequestCountRecord(0L, 1L))
								.reduce(new RequestCountRecord(0L, 0L), (a, b) -> new RequestCountRecord(
										a.questionCount() + b.questionCount(),
										a.submissionCount() + b.submissionCount())))));
	}

	/**
	 * Returns a mapping from each lab in an edition to a view dto which contains the average waiting time and
	 * average processing time for that lab.
	 *
	 * @param  requests The filtered requests to consider
	 * @return          The mapping as described above
	 */
	public Map<String, LabStatisticsViewDto> countWaitProcessingTimePerLab(List<LabRequest> requests) {

		final LocalDateTime now = LocalDateTime.now();

		Map<String, List<LabRequest>> groupedLabRequests = groupRequestsByLab(requests);

		return new TreeMap<>(groupedLabRequests.entrySet().stream()
				.collect(Collectors.toMap(
						Map.Entry::getKey,
						entry -> LabStatisticsViewDto.builder()
								.avgWaitingTime(averageWaitingTime(entry.getValue(), ChronoUnit.MINUTES, now))
								.avgProcessingTime(
										averageProcessingTime(entry.getValue(), ChronoUnit.MINUTES, now))
								.build())));
	}

	/**
	 * Sorts lab requests by lab start date for chronological statistics
	 *
	 * @param  requests The requests to consider
	 * @return          The requests returned in order of the chronological ordering of the lab they belong
	 *                  to.
	 */
	private Map<String, List<LabRequest>> groupRequestsByLab(List<LabRequest> requests) {
		// fetch all associated requests to in-memory cache.
		sCache.getAndIgnoreMissing(requests.stream().map(rq -> rq.getSession().getId()).distinct());

		// It's important we group by ids here and not names, as most people create lab series which have the same names
		Map<Long, List<LabRequest>> resWithIds = requests.stream()
				.collect(Collectors.groupingBy(r -> r.getSession().getId()));

		return resWithIds.entrySet().stream()
				.collect(Collectors.toMap(
						entry -> {
							var session = sCache.getRequired(entry.getKey());
							return session.getId() + " - " + session.getName();
						},
						Map.Entry::getValue));
	}

	/**
	 * Returns the min of two LocalDateTime instances. If one instance is null, it will return the other one
	 *
	 * @param  d1                       date1
	 * @param  d2                       date2
	 * @throws IllegalArgumentException If the method is provided with 2 instances that are null
	 * @return                          The one that preceeded the other instance in terms of time
	 */
	private LocalDateTime min(LocalDateTime d1, LocalDateTime d2) {
		if (d1 == null && d2 == null)
			throw new IllegalArgumentException("This should not happen");
		if (d1 == null)
			return d2;
		if (d2 == null)
			return d1;
		return d1.isBefore(d2) ? d1 : d2;
	}
}
