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

import static nl.tudelft.labracore.api.dto.RoleCreateDTO.TypeEnum.TA;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.hasSize;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.log;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

import java.util.List;

import org.hamcrest.Matchers;
import org.hamcrest.core.StringContains;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.ArgumentCaptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.security.test.context.support.WithUserDetails;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;

import jakarta.transaction.Transactional;
import nl.tudelft.labracore.api.EditionControllerApi;
import nl.tudelft.labracore.api.RoleControllerApi;
import nl.tudelft.labracore.api.dto.*;
import nl.tudelft.labracore.api.dto.EditionDetailsDTO;
import nl.tudelft.labracore.api.dto.EditionSummaryDTO;
import nl.tudelft.labracore.api.dto.PersonSummaryDTO;
import nl.tudelft.labracore.api.dto.RoleDetailsDTO;
import nl.tudelft.labracore.api.dto.RoleId;
import nl.tudelft.labracore.api.dto.SessionDetailsDTO;
import nl.tudelft.queue.dto.util.EditionFilterDTO;
import nl.tudelft.queue.dto.view.QueueEditionDetailsDTO;
import nl.tudelft.queue.model.QueueEdition;
import nl.tudelft.queue.model.labs.Lab;
import nl.tudelft.queue.repository.QueueEditionRepository;
import nl.tudelft.queue.service.EditionService;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import test.TestDatabaseLoader;
import test.labracore.*;
import test.test.TestQueueApplication;

@Transactional
@AutoConfigureMockMvc
@SpringBootTest(classes = TestQueueApplication.class)
public class EditionControllerTest {
	@Autowired
	private TestDatabaseLoader db;

	@Autowired
	private MockMvc mvc;

	@Autowired
	private EditionControllerApi eApi;

	@Autowired
	private RoleControllerApi rApi;

	@Autowired
	private CourseApiMocker cApiMocker;

	@Autowired
	private EditionApiMocker eApiMocker;

	@Autowired
	private ModuleApiMocker mApiMocker;

	@Autowired
	private EditionCollectionApiMocker ecApiMocker;

	@Autowired
	private PersonApiMocker pApiMocker;

	@Autowired
	private RoleApiMocker rApiMocker;

	@Autowired
	private SessionApiMocker sApiMocker;

	@Autowired
	private StudentGroupApiMocker sgApiMocker;

	@Autowired
	private QueueEditionRepository qer;

	@Autowired
	private EditionService es;

	private EditionDetailsDTO oopNow;
	private SessionDetailsDTO session1;
	private Lab lab1;

	private PersonSummaryDTO student100;
	private PersonSummaryDTO student155;

	private PersonSummaryDTO teacher100;

	@BeforeEach
	void setUp() {
		cApiMocker.mock();
		eApiMocker.mock();
		mApiMocker.mock();
		rApiMocker.mock();
		sApiMocker.mock();
		sgApiMocker.mock();
		ecApiMocker.mock();
		pApiMocker.mock();

		student100 = db.getStudents()[100];
		student155 = db.getStudents()[155];
		teacher100 = db.getTeachers()[100];

		oopNow = db.getOopNow();
		session1 = sApiMocker.getById(db.getOopNowRegularLab1().getSession()).get();
		lab1 = db.getOopNowRegularLab1();
	}

	@Test
	@WithUserDetails("teacher0")
	void getEmptyCourseListContainsModelAttributes() throws Exception {
		when(eApi.getAllEditionsActiveOrTaughtBy(any()))
				.thenReturn(Flux.just());

		when(eApi.getAllEditionsActiveDuringPeriod(any(), any())).thenReturn(Flux.just());

		mvc.perform(get("/editions"))
				.andExpect(status().isOk())
				.andExpect(model().attribute("page", "catalog"))
				.andExpect(model().attributeExists("editions"));
	}

	@Test
	@WithUserDetails("teacher0")
	void submitFiltersRedirectsBackToEditions() throws Exception {
		when(eApi.getAllEditionsActiveDuringPeriod(any(), any())).thenReturn(Flux.just());

		MockHttpSession session = new MockHttpSession();

		mvc.perform(post("/editions/filter").with(csrf())
				.session(session)
				.queryParam("programs", "2", "33")
				.queryParam("nameSearch", "Cool name"))
				.andExpect(redirectedUrlPattern("/editions*"));
	}

	@Test
	@WithUserDetails("teacher0")
	void submitFiltersRemembersFilter() throws Exception {
		when(eApi.getAllEditionsActiveOrTaughtBy(any()))
				.thenReturn(Flux.just());

		when(eApi.getAllEditionsActiveDuringPeriod(any(), any())).thenReturn(Flux.just());

		MockHttpSession session = new MockHttpSession();

		mvc.perform(post("/editions/filter").with(csrf())
				.session(session)
				.queryParam("programs", "2", "33")
				.queryParam("nameSearch", "Cool name"));

		mvc.perform(get("/editions")
				.session(session))
				.andExpect(model().attribute("filter", new EditionFilterDTO(List.of(2L, 33L), "Cool name")));
	}

	@Test
	@WithUserDetails("teacher0")
	void getCourseRequestPageWorks() throws Exception {
		mvc.perform(get("/editions/request-course"))
				.andExpect(status().isOk())
				.andExpect(model().attributeExists("dto"))
				.andExpect(view().name("course/request"));
	}

	@Test
	@WithUserDetails("student155")
	void allEditionsNotExposedToStudent() throws Exception {
		when(eApi.getAllEditionsActiveOrTaughtBy(any()))
				.thenReturn(Flux.just());

		when(eApi.getAllEditionsActiveDuringPeriod(any(), any())).thenReturn(Flux.just());

		mvc.perform(get("/editions"))
				.andExpect(status().isOk())
				.andExpect(model().attribute("page", "catalog"))
				.andExpect(model().attributeExists("editions"))
				.andExpect(model().attributeDoesNotExist("allEditions"));
	}

	@Test
	@WithUserDetails("student155")
	void getEditionEnrolViewContainsEdition() throws Exception {
		qer.save(QueueEdition.builder().id(oopNow.getId()).hidden(false).build());
		mvc.perform(get("/edition/{id}/enrol", oopNow.getId()))
				.andExpect(status().isOk())
				.andExpect(model().attribute("edition", oopNow));
	}

	@Test
	@WithUserDetails("student42")
	void enrolAfterEnrolledRedirectsBackToEditions() throws Exception {
		rApiMocker.save(new RoleDetailsDTO().id(new RoleId().personId(42L)
				.editionId(oopNow.getId())).type(RoleDetailsDTO.TypeEnum.STUDENT)
				.edition(new EditionSummaryDTO().id(oopNow.getId()))
				.person(new PersonSummaryDTO().id(42L)));

		mvc.perform(get("/edition/{id}/enrol", oopNow.getId()))
				.andExpect(status().is3xxRedirection())
				.andExpect(redirectedUrlPattern("/editions*"));
	}

	// This test fails, because the actual status will be a permission failure.
	// TODO: Fix this test by actually giving 404s when an object cannot be found.
	//    @Test
	//    @WithUserDetails("student")
	//    void getUnknownEditionEnrolViewGives404() throws Exception {
	//        when(ps.canEnrolForEdition(67326732L)).thenReturn(false);
	//
	//        mvc.perform(get("/edition/{id}/enrol", 67326732L))
	//                .andExpect(status().isNotFound());
	//    }

	@Test
	@WithUserDetails("student155")
	void studentsShouldBeAbleToPostToEnrol() throws Exception {
		qer.save(QueueEdition.builder().id(oopNow.getId()).hidden(false).build());
		when(eApi.addStudentsToEdition(any(), any(), any())).thenReturn(Mono.empty());

		mvc.perform(post("/edition/{id}/enrol", oopNow.getId()).with(csrf()))
				.andExpect(redirectedUrl("/edition/" + oopNow.getId()));

		verify(eApi).addStudentsToEdition(List.of("student155"), oopNow.getId(), false);
	}

	@Test
	@WithUserDetails("student155")
	void everyoneShouldBeAbleToSeeEditionInfoIfEditionIsNotHidden() throws Exception {
		qer.save(QueueEdition.builder().id(oopNow.getId()).hidden(false).build());
		mvc.perform(get("/edition/{id}", oopNow.getId()))
				.andExpect(status().isOk())
				.andExpect(model().attribute("edition",
						es.queueEditionDTO(oopNow, QueueEditionDetailsDTO.class)));
	}

	@Test
	@WithUserDetails("student155")
	void nonMembersShouldNotBeAbleToSeeEditionInfoIfEditionIsHidden() throws Exception {
		qer.save(QueueEdition.builder().id(oopNow.getId()).hidden(true).build());
		mvc.perform(get("/edition/{id}", oopNow.getId()))
				.andExpect(status().isForbidden());
	}

	@Test
	@WithUserDetails("student100")
	void getEditionParticipantsViewShouldWork() throws Exception {
		mvc.perform(get("/edition/{id}/participants", oopNow.getId()))
				.andExpect(view().name("edition/view/participants"))
				.andExpect(model().attributeExists("edition", "students"));
	}

	@Test
	@WithUserDetails("student101")
	void getEditionParticipantsViewWorksWhenStudentSearchHasString() throws Exception {
		mvc.perform(get("/edition/{id}/participants", oopNow.getId())
				.queryParam("student-search", "1"))
				.andExpect(view().name("edition/view/participants"))
				.andExpect(model().attributeExists("edition", "students"));
	}

	@Test
	@WithUserDetails("student101")
	void getEditionParticipantsViewWorksWhenStudentSearchEmpty() throws Exception {
		mvc.perform(get("/edition/{id}/participants", oopNow.getId())
				.queryParam("student-search", ""))
				.andExpect(view().name("edition/view/participants"))
				.andExpect(model().attributeExists("edition", "students"));
	}

	@Test
	@WithUserDetails("student100")
	void participantLeaveShouldCallRemoveRole() throws Exception {
		when(eApi.removePersonFromEdition(any(), any())).thenReturn(Mono.empty());

		mvc.perform(post("/edition/" + oopNow.getId() + "/leave").with(csrf()))
				.andExpect(redirectedUrl("/edition/" + oopNow.getId()));

		verify(eApi).removePersonFromEdition(oopNow.getId(), student100.getId());
	}

	@Test
	@WithUserDetails("student50")
	void labsPageShouldContainSessions() throws Exception {
		mvc.perform(get("/edition/" + oopNow.getId() + "/labs"))
				.andExpect(view().name("edition/view/labs"))
				.andExpect(content().string(new StringContains(session1.getName())))
				.andDo(log());
	}

	@Test
	@WithUserDetails("teacher0")
	void participantCreateShouldCallAddRole() throws Exception {
		when(rApi.addRole(any())).thenReturn(Mono.empty());

		mvc.perform(post("/edition/" + oopNow.getId() + "/participants/create")
				.queryParam("identifier", "student100")
				.queryParam("type", "TA").with(csrf()))
				.andExpect(redirectedUrl("/edition/" + oopNow.getId() + "/participants"));

		ArgumentCaptor<RoleCreateDTO> captor = ArgumentCaptor.forClass(RoleCreateDTO.class);
		verify(rApi).addRole(captor.capture());
		RoleCreateDTO role = captor.getValue();

		assertThat(role.getPerson().getId()).isEqualTo(student100.getId());
		assertThat(role.getEdition().getId()).isEqualTo(oopNow.getId());
		assertThat(role.getType()).isEqualTo(TA);
	}

	@Test
	@WithUserDetails("teacher0")
	void demoteParticipantStillCallsAddRole() throws Exception {
		when(rApi.addRole(any())).thenReturn(Mono.empty());

		mvc.perform(
				post("/edition/" + oopNow.getId() + "/participants/" + teacher100.getId() + "/demote/" + TA)
						.queryParam("type", "TA")
						.with(csrf()))
				.andExpect(redirectedUrl("/edition/" + oopNow.getId() + "/participants"));

		ArgumentCaptor<RoleCreateDTO> captor = ArgumentCaptor.forClass(RoleCreateDTO.class);
		verify(rApi).addRole(captor.capture());
		RoleCreateDTO role = captor.getValue();

		assertThat(role.getPerson().getId()).isEqualTo(teacher100.getId());
		assertThat(role.getEdition().getId()).isEqualTo(oopNow.getId());
		assertThat(role.getType()).isEqualTo(TA);
	}

	@Test
	@WithUserDetails("teacher0")
	void teachersShouldNotDemoteThemselvesIfThereIsOneTeacher() throws Exception {
		when(rApi.addRole(any())).thenReturn(Mono.empty());

		mvc.perform(post("/edition/" + oopNow.getId() + "/participants/create")
				.queryParam("identifier", "teacher0")
				.queryParam("type", "HEAD_TA").with(csrf()))
				.andExpect(flash().attributeExists("error"));
	}

	@Test
	@WithUserDetails("teacher0")
	void checkDemoteRedirectsToRightDemotePage() throws Exception {
		when(rApi.addRole(any())).thenReturn(Mono.empty());

		rApiMocker.save(new RoleDetailsDTO().id(new RoleId().personId(teacher100.getId())
				.editionId(oopNow.getId())).type(RoleDetailsDTO.TypeEnum.TEACHER)
				.edition(new EditionSummaryDTO().id(oopNow.getId()))
				.person(new PersonSummaryDTO().id(teacher100.getId()).username(teacher100.getUsername()).displayName(teacher100.getDisplayName())));

		mvc.perform(post("/edition/" + oopNow.getId() + "/participants/create")
				.queryParam("identifier", teacher100.getUsername())
				.queryParam("type", "HEAD_TA").with(csrf()))
				.andExpect(view().name("edition/view/demote"));
	}

	@Test
	@WithUserDetails("admin")
	void postEmptyCsvFile() throws Exception {
		MockMultipartFile file = new MockMultipartFile(
				"file",
				"test.csv",
				MediaType.TEXT_PLAIN_VALUE,
				"".getBytes());

		mvc.perform(multipart("/edition/{editionId}/participants/import", oopNow.getId()).file(file)
				.with(csrf())).andExpect(status().is3xxRedirection())
				.andExpect(flash().attributeExists("error"));
	}

	@Test
	@WithUserDetails("admin")
	void toggleEdition() throws Exception {
		mvc.perform(post("/edition/1/visibility").with(csrf()))
				.andExpect(status().is3xxRedirection());
		assertThat(qer.findById(1L)).isPresent().hasValueSatisfying(e -> assertThat(e.getHidden()).isFalse());
		mvc.perform(post("/edition/1/visibility").with(csrf()))
				.andExpect(status().is3xxRedirection());
		assertThat(qer.findById(1L)).isPresent().hasValueSatisfying(e -> assertThat(e.getHidden()).isTrue());
	}

	@Test
	@WithUserDetails("teacher0")
	void searchPeopleByEmptyIdentifier() throws Exception {
		mvc.perform(get("/edition/" + oopNow.getId() + "/search-people")
				.queryParam("identifier", ""))
				.andExpect(status().isOk())
				.andExpect(model().attribute("people", hasSize(0)));
	}

	@Test
	@WithUserDetails("teacher0")
	void searchPeopleBySmallIdentifier() throws Exception {
		mvc.perform(get("/edition/" + oopNow.getId() + "/search-people")
				.queryParam("identifier", "1"))
				.andExpect(status().isOk())
				.andExpect(model().attribute("people", hasSize(0)));
	}

	@Test
	@WithUserDetails("teacher0")
	void searchPeopleByBogusIdentifier() throws Exception {
		mvc.perform(get("/edition/" + oopNow.getId() + "/search-people")
				.queryParam("identifier", "blablabla"))
				.andExpect(status().isOk())
				.andExpect(model().attribute("people", hasSize(0)));
	}

	@Test
	@WithUserDetails("teacher0")
	void searchPeopleByNumber() throws Exception {
		mvc.perform(get("/edition/" + oopNow.getId() + "/search-people")
				.queryParam("identifier", "832743"))
				.andExpect(status().isOk())
				.andExpect(model().attribute("people", hasSize(10)));
	}

	@Test
	@WithUserDetails("teacher0")
	void searchPeopleByEmail() throws Exception {
		mvc.perform(get("/edition/" + oopNow.getId() + "/search-people")
				.queryParam("identifier", "student155@student.edumail.com"))
				.andExpect(status().isOk())
				.andExpect(model().attribute("people", hasSize(1)))
				.andExpect(model().attribute("people", Matchers.contains(student155)));
	}

	@ParameterizedTest
	@MethodSource(value = "protectedEndpoints")
	void testWithoutUserDetailsIsForbidden(MockHttpServletRequestBuilder request) throws Exception {
		mvc.perform(request.with(csrf()))
				.andExpect(status().is3xxRedirection())
				.andExpect(redirectedUrl("http://localhost/login"));
	}

	private static List<MockHttpServletRequestBuilder> protectedEndpoints() {
		return List.of(
				get("/editions"),
				post("/editions/filter"),
				get("/editions/request-course"),
				get("/edition/1/enrol"),
				post("/edition/1/enrol"),
				get("/edition/1"),
				get("/edition/1/participants"),
				get("/edition/1/modules"),
				get("/edition/1/labs"),
				get("/edition/1/participants/create"),
				post("/edition/1/participants/create"),
				get("/edition/1/participants/1/remove"),
				post("/edition/1/participants/1/remove"),
				post("/edition/1/participants/1/block"),
				get("/edition/1/leave"),
				post("/edition/1/leave"),
				post("/edition/1/toggle"),
				get("/edition/1/status"),
				get("/edition/1/search-people"));
	}

	@Test
	@WithUserDetails("teacher0")
	void getArchiveEditionGetsEdition() throws Exception {
		mvc.perform(get("/edition/" + oopNow.getId() + "/archive"))
				.andExpect(view().name("edition/view/archive"))
				.andExpect(model().attribute("edition", oopNow));
	}

	@Test
	@WithUserDetails("teacher0")
	void teacherShouldBeAbleToArchive() throws Exception {

		when(eApi.archiveEdition(any())).thenReturn(Mono.empty());
		mvc.perform(post("/edition/" + oopNow.getId() + "/archive").with(csrf()))
				.andExpect(redirectedUrl("/edition/" + oopNow.getId()));

		verify(eApi).archiveEdition(oopNow.getId());
	}

}
