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

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

import javax.transaction.Transactional;

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.PersonCacheManager;
import nl.tudelft.queue.cache.RoomCacheManager;
import nl.tudelft.queue.cache.SessionCacheManager;
import nl.tudelft.queue.model.LabRequest;
import nl.tudelft.queue.model.QLabRequest;
import nl.tudelft.queue.model.enums.RequestType;
import nl.tudelft.queue.model.labs.Lab;
import nl.tudelft.queue.repository.LabRequestRepository;

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

@Service
public class EditionStatusService {
	@Autowired
	private AssignmentCacheManager aCache;

	@Autowired
	private SessionCacheManager sCache;

	@Autowired
	private RoomCacheManager rCache;

	@Autowired
	private LabRequestRepository rr;

	@Autowired
	private PersonCacheManager pCache;

	@Autowired
	@Lazy
	private LabService ls;

	/**
	 * 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 = sCache.getAndHandleAll(labs.stream().map(Lab::getSession), ls.deleteSessionsByIds());

		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) {
		QLabRequest qlr = QLabRequest.labRequest;

		return getFilteredRequests(qlr.session.in(labs)
				.and(qlr.assignment.in(assignments))
				.and(qlr.room.in(rooms)).and(qlr.requestType.in(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) {
		QLabRequest qlr = QLabRequest.labRequest;

		return getFilteredRequests(qlr.session.in(labs));
	}

	/**
	 * Gets the list of requests for which the given predicate holds.
	 *
	 * @param  p The predicate that should hold for the returned requests.
	 * @return   The list of requests for which the predicate holds.
	 */
	public List<LabRequest> getFilteredRequests(com.querydsl.core.types.Predicate p) {
		final Iterable<LabRequest> requests = rr.findAll(p);
		return StreamSupport.stream(requests.spliterator(), false)
				.collect(Collectors.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());
	}

	/**
	 * Creates a mapping of the given list of assignments to the number of times each of the assignments
	 * occurs within the given list of requests.
	 *
	 * @param  assignments The assignments to count for.
	 * @param  requests    The requests to count.
	 * @return             The mapping from assignment names to the number of times each of the corresponding
	 *                     assignments occurs in the list of requests.
	 */
	public Map<String, Long> countRequestsPerAssignment(List<Long> assignments,
			List<LabRequest> requests) {
		return aCache.getAndIgnoreMissing(assignments).stream()
				.collect(Collectors.toMap(
						a -> a.getName() + " (#" + a.getId() + ")",
						a -> countWhere(requests, r -> r.getAssignment().equals(a.getId()))));
	}

	/**
	 * 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(r -> r.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();
	}

	/**
	 * 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 the assistant name to the amount of requests.
	 */
	public Map<String, Long> countRequestsPerAssistant(List<LabRequest> requests) {
		List<Long> ids = requests.stream()
				.map(r -> r.getEventInfo().getAssignedTo())
				.distinct()
				.filter(Objects::nonNull)
				.collect(Collectors.toList());
		Map<Long, String> names = pCache.getAndIgnoreMissing(ids)
				.stream()
				.collect(Collectors.toMap(
						PersonSummaryDTO::getId,
						PersonSummaryDTO::getDisplayName));
		Map<String, Long> res = requests.stream()
				.collect(Collectors.toMap(
						r -> names.get(r.getEventInfo().getAssignedTo()),
						r -> 1L,
						Long::sum));
		res.remove(null);
		return res;
	}

	/**
	 * 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.
	 * @return          The average time between creating a request and a TA starting to process it.
	 */
	public Long averageWaitingTime(List<LabRequest> requests) {
		return (long) requests.stream()
				.filter(r -> {
					var status = r.getEventInfo().getStatus();
					return (status.isProcessing() ||
							status.isHandled() && r.getEventInfo().getLastAssignedAt() != null);
				})
				.mapToLong(r -> {
					if (r.getTimeSlot() != null) {
						return ChronoUnit.SECONDS.between(
								r.getTimeSlot().getSlot().getOpensAt(), r.getEventInfo().getLastAssignedAt());
					} else {
						return ChronoUnit.SECONDS.between(
								r.getCreatedAt(), r.getEventInfo().getLastAssignedAt());
					}
				})
				.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 all requests that are handled in the given list of requests.
	 *
	 * @param  requests The list of requests to calculate over.
	 * @return          The average time between processing a request and it being handled.
	 */
	public Long averageProcessingTime(List<LabRequest> requests) {
		return (long) requests.stream()
				.filter(r -> r.getEventInfo().getStatus().isHandled() &&
						r.getEventInfo().getLastAssignedAt() != null &&
						r.getEventInfo().getHandledAt() != null)
				.mapToLong(r -> ChronoUnit.SECONDS.between(
						r.getEventInfo().getLastAssignedAt(), r.getEventInfo().getHandledAt()))
				.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());
	}
}
