/*
 * 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.LocalDateTime.now;
import static java.time.temporal.ChronoUnit.MINUTES;
import static java.util.stream.Collectors.groupingBy;

import java.io.IOException;
import java.util.*;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;

import org.apache.commons.lang3.StringUtils;
import org.modelmapper.ModelMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
import org.springframework.util.StreamUtils;
import org.springframework.web.multipart.MultipartFile;

import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import jakarta.transaction.Transactional;
import nl.tudelft.labracore.api.EditionControllerApi;
import nl.tudelft.labracore.api.PersonControllerApi;
import nl.tudelft.labracore.api.RoleControllerApi;
import nl.tudelft.labracore.api.dto.*;
import nl.tudelft.queue.cache.AssignmentCacheManager;
import nl.tudelft.queue.cache.CourseCacheManager;
import nl.tudelft.queue.csv.*;
import nl.tudelft.queue.dto.util.EditionFilterDTO;
import nl.tudelft.queue.dto.view.QueueEditionViewDTO;
import nl.tudelft.queue.dto.view.QueueSessionSummaryDTO;
import nl.tudelft.queue.model.LabRequest;
import nl.tudelft.queue.model.QueueEdition;
import nl.tudelft.queue.model.QueueSession;
import nl.tudelft.queue.model.embeddables.AllowedRequest;
import nl.tudelft.queue.model.enums.QueueSessionType;
import nl.tudelft.queue.model.enums.RequestType;
import nl.tudelft.queue.model.labs.Lab;
import nl.tudelft.queue.repository.QueueEditionRepository;
import nl.tudelft.queue.repository.QueueSessionRepository;

@Service
public class EditionService {

	private static final ModelMapper mapper = new ModelMapper();

	@Autowired
	private HttpSession session;

	@Autowired
	private QueueSessionRepository qsr;

	@Autowired
	private PersonControllerApi pApi;

	@Autowired
	private RoleControllerApi rApi;

	@Autowired
	private EditionControllerApi eca;

	@Autowired
	private QueueEditionRepository qer;

	@Autowired
	@Lazy
	private LabService ls;

	@Autowired
	private FileService fs;

	@Autowired
	@Lazy
	private RoleDTOService rs;

	@Autowired
	private AssignmentCacheManager acm;

	@Autowired
	private CourseCacheManager cCache;

	/**
	 * Converts any kind of EditionDTO to a QueueEditionDTO.
	 *
	 * @param  dto    The DTO to convert
	 * @param  qClass The class of QueueEditionDTO to convert to
	 * @return        The converted DTO
	 */
	public <DTO, QDTO extends QueueEditionViewDTO> QDTO queueEditionDTO(DTO dto, Class<QDTO> qClass) {
		return queueEditionDTO(List.of(dto), qClass).get(0);
	}

	/**
	 * Converts a list of any kind of EditionDTOs to QueueEditionDTOs.
	 *
	 * @param  dtos   The DTOs to convert
	 * @param  qClass The class of QueueEditionDTO to convert to
	 * @return        The converted list of DTOs
	 */
	public <DTO, QDTO extends QueueEditionViewDTO> List<QDTO> queueEditionDTO(List<DTO> dtos,
			Class<QDTO> qClass) {
		return dtos.stream().map(dto -> {
			QDTO qDto = mapper.map(dto, qClass);
			QueueEdition qEdition = getOrCreateQueueEdition(qDto.getId());
			qDto.setHidden(qEdition.getHidden());
			return qDto;
		}).toList();
	}

	/**
	 * Filters a list of editions by an edition filter.
	 *
	 * @param  editions The original list of editions
	 * @param  filter   The filter to apply
	 * @return          The filtered list of editions
	 */
	public List<EditionDetailsDTO> filterEditions(List<EditionDetailsDTO> editions, EditionFilterDTO filter) {
		Stream<EditionDetailsDTO> filtered = editions.stream();

		if (filter.getPrograms() != null && !filter.getPrograms().isEmpty()) {
			Set<Long> programFilter = new HashSet<>(filter.getPrograms());
			cCache.getAndIgnoreMissing(editions.stream().map(e -> e.getCourse().getId()).distinct());
			filtered = filtered
					.filter(e -> programFilter
							.contains(cCache.getRequired(e.getCourse().getId()).getProgram().getId()));
		}

		if (filter.getNameSearch() != null && !filter.getNameSearch().isBlank()) {
			String lcNameFilter = filter.getNameSearch().toLowerCase();
			filtered = filtered.filter(e -> e.getName().toLowerCase().contains(lcNameFilter) ||
					e.getCourse().getName().toLowerCase().contains(lcNameFilter) ||
					e.getCourse().getCode().toLowerCase().contains(lcNameFilter));
		}

		return filtered.toList();
	}

	/**
	 * Gets a queue edition from the database or creates a default one if it does not exist;
	 *
	 * @param  id The id of the edition
	 * @return    The Queue Edition
	 */
	@Transactional
	public QueueEdition getOrCreateQueueEdition(Long id) {
		return qer.findById(id).orElseGet(() -> qer.save(QueueEdition.builder().id(id).build()));
	}

	/**
	 * Filters a list of people on their username, display name and student number. If any of the
	 * aforementioned values match the given search term, the person is included. If not, the person is
	 * excluded.
	 *
	 * @param  people     The people through which we need to filter.
	 * @param  searchTerm The original search term that was typed by the user.
	 * @return            The filtered list of people (this could be empty).
	 */
	public List<PersonSummaryDTO> studentsMatchingFilter(List<PersonSummaryDTO> people,
			String searchTerm) {
		var search = searchTerm.toLowerCase();

		return people.stream()
				.filter(person -> person.getDisplayName() != null
						&& StringUtils.stripAccents(person.getDisplayName())
								.toLowerCase().contains(search)
						||
						person.getUsername() != null && StringUtils.stripAccents(person.getUsername())
								.toLowerCase().contains(search)
						||
						Objects.toString(person.getNumber()).contains(search))
				.collect(Collectors.toList());
	}

	/**
	 * Sorts the labs on the following criteria, decreasing in priority, active, in the past/future, start
	 * date from current date, alphabetical.
	 *
	 * @param  labs the list of labs to be sorted.
	 * @return      the sorted list of labs.
	 */
	public List<QueueSessionSummaryDTO> sortLabs(List<QueueSessionSummaryDTO> labs) {
		return labs.stream()
				.sorted(Comparator.comparing((QueueSessionSummaryDTO lab) -> !lab.getSlot().open())
						.thenComparing(
								(QueueSessionSummaryDTO lab) -> !lab.getSlot().getOpensAt().isAfter(now()))
						.thenComparing((QueueSessionSummaryDTO lab) -> lab.getSlot().getOpensAt()
								.truncatedTo(MINUTES))
						.thenComparing(QueueSessionSummaryDTO::getName))
				.collect(Collectors.toList());
	}

	/**
	 * Filters the labs on their type.
	 *
	 * @param  labs    the list of labs to filter.
	 * @param  types   the types of labs that should remain. No filtering is performed when empty.
	 * @param  modules the modules that should be part of the lab. No filtering is performed when empty.
	 * @return         the filtered list of labs
	 */
	public List<QueueSessionSummaryDTO> filterLabs(List<QueueSessionSummaryDTO> labs,
			List<QueueSessionType> types, List<Long> modules) {
		Map<Long, Set<Long>> modulesMap = null;
		if (!modules.isEmpty()) {
			List<Long> labIds = labs.stream().map(QueueSessionSummaryDTO::getId).collect(Collectors.toList());
			modulesMap = qsr.findAllById(labIds).stream()
					.collect(Collectors.toMap(QueueSession::getId, QueueSession::getModules));
		}
		final Map<Long, Set<Long>> finalModulesMap = modulesMap;
		return labs.stream()
				.filter(lab -> types.isEmpty() || types.contains(lab.getType()))
				.filter(lab -> modules.isEmpty()
						|| !Collections.disjoint(finalModulesMap.get(lab.getId()), modules))
				.collect(Collectors.toList());
	}

	/**
	 * Add participants according a CSV file to a specific editions.
	 *
	 * @param  csv         CSF file containing netid's and roles.
	 * @param  edition     The edition to which these people need to be added.
	 * @throws IOException
	 */
	public void addCourseParticipants(MultipartFile csv, EditionDetailsDTO edition)
			throws EmptyCsvException, InvalidCsvValuesException, InvalidCsvException {
		List<UserCsvHelper> users = UserCsvHelper.readCsv(csv);
		List<UserCsvHelper> allDemotes = new ArrayList<>();
		int addedTeachersCount = 0;
		List<PersonSummaryDTO> currentTeachers = rs.teachers(edition);

		for (UserCsvHelper csvUser : users) {
			PersonDetailsDTO csvPersonDetails = pApi.getPersonByUsername(csvUser.getNetId()).block();
			// Keep count of all the demotes, do not process them yet
			if (rs.isTeacherInEdition(csvPersonDetails.getId(), edition)
					&& !csvUser.getType().equals(RoleCreateDTO.TypeEnum.TEACHER)) {
				allDemotes.add(csvUser);
				continue;
			}

			// Keep count of new teachers that were added and still process them
			if (csvUser.getType().equals(RoleCreateDTO.TypeEnum.TEACHER) &&
					!rs.isTeacherInEdition(csvPersonDetails.getId(), edition)) {
				addedTeachersCount++;
			}

			rApi.addRole(csvUser.person(new PersonIdDTO().id(csvPersonDetails.getId()))
					.edition(new EditionIdDTO().id(edition.getId())))
					.block();
		}
		resolveDemotes(edition, currentTeachers, addedTeachersCount, allDemotes);
	}

	/**
	 * Checks if any teachers would be left in the edition if all demotes are processed, demotes them if there
	 * is at least 1 teacher left, does not process the demotes and throws InvalidCsvValuesException if not
	 *
	 * @param  edition                   the edition the demotes are in
	 * @param  currentTeachers           the list of teachers before the whole csv file was processed
	 * @param  addedTeachersCount        the amount of teachers newly added by the csv file
	 * @param  allDemotes                list of all demotes that are in the csv file
	 * @throws InvalidCsvValuesException exception thrown when only s subset of the csv file could not be
	 *                                   processed because of invalid values
	 */
	private void resolveDemotes(EditionDetailsDTO edition, List<PersonSummaryDTO> currentTeachers,
			int addedTeachersCount, List<UserCsvHelper> allDemotes) throws InvalidCsvValuesException {
		// if the demotes leave no teachers
		if (currentTeachers.size() + addedTeachersCount - allDemotes.size() < 1) {
			throw new InvalidCsvValuesException(
					"Import happened but teachers were not demoted as that would leave no teachers");
		}

		// if the demotes are valid and leaves teachers in edition, process them
		for (UserCsvHelper csvUser : allDemotes) {
			pApi.getPersonByUsername(csvUser.getNetId())
					.flatMap(person -> rApi.addRole(csvUser.person(new PersonIdDTO().id(person.getId()))
							.edition(new EditionIdDTO().id(edition.getId()))))
					.block();
		}
	}

	/**
	 * Stores the filter in the session.
	 *
	 * @param  filter The filter to store.
	 * @param  key    The key used to store the filter inside the session.
	 * @return        The finalized and validated filter.
	 */
	public EditionFilterDTO storeFilter(EditionFilterDTO filter, String key) {
		session.setAttribute(key, filter);

		return filter;
	}

	/**
	 * Gets the filter from the session.
	 *
	 * @param  key The key used to retrieve the filter inside the session.
	 * @return     The filter.
	 */
	public EditionFilterDTO getFilter(String key) {
		EditionFilterDTO filter = (EditionFilterDTO) session.getAttribute(key);
		return (filter == null) ? new EditionFilterDTO() : filter;
	}

	/**
	 * Export all labs in an edition to a zip file.
	 *
	 * @param  editionId   The id of the edition for which labs should be exported.
	 * @param  response    The repsonse which should be filled with the zip file.
	 * @throws IOException
	 */
	public void editionToZip(Long editionId, HttpServletResponse response) throws IOException {
		var editionDetails = eca.getEditionById(editionId).block();
		assert editionDetails != null;
		List<Resource> resources = new ArrayList<>();
		for (SessionSummaryDTO s : editionDetails.getSessions()) {
			List<Resource> resourceList = ls.sessionDTOToCsv(s);
			resources.addAll(resourceList);
		}
		try (ZipOutputStream zippedOut = new ZipOutputStream(response.getOutputStream())) {
			for (Resource file : resources) {
				ZipEntry entry = new ZipEntry(Objects.requireNonNull(file.getFilename()));
				entry.setSize(file.contentLength());
				entry.setTime(System.currentTimeMillis());
				zippedOut.putNextEntry(entry);
				StreamUtils.copy(file.getInputStream(), zippedOut);
				zippedOut.closeEntry();
			}
			zippedOut.finish();
		}

	}

	/**
	 * Export latest submission requests for an assigment per student to csv.
	 *
	 * @param  editionId   The edition for which requests should be exported.
	 * @return             A csv resource containing all the relevant requests.
	 * @throws IOException
	 */
	public Resource submissionLabsToCsv(Long editionId) throws IOException {
		var sessionsStream = eca.getEditionById(editionId).block().getSessions().stream().map(s -> s.getId())
				.toList();
		var sessions = qsr.findAllBySessions(sessionsStream);
		var assignments = sessions.stream().map(s -> (Lab) s).map(Lab::getAllowedRequests)
				.flatMap(r -> r.stream().map(AllowedRequest::getAssignment)).map(a -> acm.get(a).get())
				.toList();
		var requests = sessions.stream()
				.flatMap(lab -> lab.getRequests().stream())
				.filter(r -> r instanceof LabRequest)
				.map(r -> (LabRequest) r)
				.filter(r -> r.getRequestType().equals(RequestType.SUBMISSION))
				.collect(groupingBy(r -> pApi.getPersonById(r.getRequester()).block(),
						groupingBy(r -> acm.get(r.getAssignment()).get())));

		//		List<String> header = new ArrayList<>(List.of("netid"));
		String[] header = new String[assignments.size() + 1];
		header[0] = "netid";
		for (int i = 0; i < assignments.size(); i++) {
			header[i + 1] = assignments.get(i).getName();
		}
		List<String[]> rows = new ArrayList<>();

		requests.forEach((requestEntity, assignmentMap) -> {
			String[] row = new String[assignments.size() + 1];
			row[0] = requestEntity.getUsername();
			for (int i = 0; i < assignments.size(); i++) {
				var requestsForAssignement = assignmentMap.getOrDefault(assignments.get(i),
						List.of());
				if (requestsForAssignement.isEmpty()) {
					row[i + 1] = "NO_REQUESTS";
				} else {
					requestsForAssignement.sort(Comparator.comparing(r -> r.getEventInfo().getLastEventAt()));
					row[i + 1] = String.valueOf(requestsForAssignement.get(0).getEventInfo().getStatus());
				}
			}
			rows.add(row);
		});

		return fs.writeTempResource("submission-requests-" + requests.hashCode() + ".csv",
				() -> CsvHelper.writeCsv(header, rows, ';'));
	}

}
