package org.raddatz.familienarchiv.geschichte;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemView;
import org.raddatz.familienarchiv.security.SecurityConfig;
import org.raddatz.familienarchiv.security.PermissionAspect;
import org.raddatz.familienarchiv.user.CustomUserDetailsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.UUID;

import static org.hamcrest.CoreMatchers.nullValue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@WebMvcTest(GeschichteController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
class GeschichteControllerTest {

    @Autowired
    MockMvc mockMvc;

    private final ObjectMapper objectMapper = new ObjectMapper();

    @MockitoBean GeschichteService geschichteService;
    @MockitoBean JourneyItemService journeyItemService;
    @MockitoBean CustomUserDetailsService customUserDetailsService;

    // ─── GET /api/geschichten ────────────────────────────────────────────────

    @Test
    void list_returns401_whenUnauthenticated() throws Exception {
        mockMvc.perform(get("/api/geschichten"))
                .andExpect(status().isUnauthorized());
    }

    @Test
    @WithMockUser(authorities = "READ_ALL")
    void list_returns200_forReader() throws Exception {
        when(geschichteService.list(any(), any(), any(), anyInt()))
                .thenReturn(List.of(summaryStub("Story A")));

        mockMvc.perform(get("/api/geschichten"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$[0].title").value("Story A"));
    }

    @Test
    @WithMockUser(authorities = "READ_ALL")
    void list_passesSinglePersonIdFilterToServiceAsListOfOne() throws Exception {
        UUID personId = UUID.randomUUID();
        when(geschichteService.list(any(), eq(List.of(personId)), any(), anyInt()))
                .thenReturn(List.of());

        mockMvc.perform(get("/api/geschichten").param("personId", personId.toString()))
                .andExpect(status().isOk());

        verify(geschichteService).list(any(), eq(List.of(personId)), any(), anyInt());
    }

    @Test
    @WithMockUser(authorities = "READ_ALL")
    void list_passesRepeatedPersonIdParamsAsListForAndFilter() throws Exception {
        UUID a = UUID.randomUUID();
        UUID b = UUID.randomUUID();
        when(geschichteService.list(any(), eq(List.of(a, b)), any(), anyInt()))
                .thenReturn(List.of());

        mockMvc.perform(get("/api/geschichten")
                        .param("personId", a.toString())
                        .param("personId", b.toString()))
                .andExpect(status().isOk());

        verify(geschichteService).list(any(), eq(List.of(a, b)), any(), anyInt());
    }

    @Test
    @WithMockUser(authorities = "READ_ALL")
    void list_passesDocumentIdFilterToService() throws Exception {
        UUID documentId = UUID.randomUUID();
        when(geschichteService.list(any(), any(), eq(documentId), anyInt()))
                .thenReturn(List.of());

        mockMvc.perform(get("/api/geschichten").param("documentId", documentId.toString()))
                .andExpect(status().isOk());

        verify(geschichteService).list(any(), any(), eq(documentId), anyInt());
    }

    @Test
    @WithMockUser(authorities = "READ_ALL")
    void list_passesLimitToService() throws Exception {
        when(geschichteService.list(any(), any(), any(), eq(5)))
                .thenReturn(List.of());

        mockMvc.perform(get("/api/geschichten").param("limit", "5"))
                .andExpect(status().isOk());

        verify(geschichteService).list(any(), any(), any(), eq(5));
    }

    @Test
    @WithMockUser(authorities = "READ_ALL")
    void list_passesStatusFilterToService() throws Exception {
        when(geschichteService.list(eq(GeschichteStatus.PUBLISHED), any(), any(), anyInt()))
                .thenReturn(List.of());

        mockMvc.perform(get("/api/geschichten").param("status", "PUBLISHED"))
                .andExpect(status().isOk());

        verify(geschichteService).list(eq(GeschichteStatus.PUBLISHED), any(), any(), anyInt());
    }

    // ─── GET /api/geschichten/{id} ───────────────────────────────────────────

    @Test
    @WithMockUser(authorities = "READ_ALL")
    void getById_returns200_whenFound() throws Exception {
        UUID id = UUID.randomUUID();
        when(geschichteService.getView(id)).thenReturn(viewStub(id, "Hello"));

        mockMvc.perform(get("/api/geschichten/{id}", id))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.id").value(id.toString()))
                .andExpect(jsonPath("$.title").value("Hello"));
    }

    @Test
    @WithMockUser(authorities = "READ_ALL")
    void getById_returns404_whenServiceThrowsNotFound() throws Exception {
        UUID id = UUID.randomUUID();
        when(geschichteService.getView(id))
                .thenThrow(DomainException.notFound(ErrorCode.GESCHICHTE_NOT_FOUND, "x"));

        mockMvc.perform(get("/api/geschichten/{id}", id))
                .andExpect(status().isNotFound())
                .andExpect(jsonPath("$.code").value("GESCHICHTE_NOT_FOUND"));
    }

    // ─── POST /api/geschichten ───────────────────────────────────────────────

    @Test
    void create_returns401_whenUnauthenticated() throws Exception {
        mockMvc.perform(post("/api/geschichten").with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"title\":\"x\"}"))
                .andExpect(status().isUnauthorized());
    }

    @Test
    @WithMockUser(authorities = "READ_ALL")
    void create_returns403_whenLackingBlogWrite() throws Exception {
        mockMvc.perform(post("/api/geschichten").with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"title\":\"x\"}"))
                .andExpect(status().isForbidden());
    }

    @Test
    @WithMockUser(authorities = "BLOG_WRITE")
    void create_returns201_withBlogWrite() throws Exception {
        UUID id = UUID.randomUUID();
        when(geschichteService.create(any(GeschichteUpdateDTO.class)))
                .thenReturn(viewStub(id, "New", GeschichteStatus.DRAFT));

        GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
        dto.setTitle("New");

        mockMvc.perform(post("/api/geschichten").with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(dto)))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.id").value(id.toString()));
    }

    // ─── PATCH /api/geschichten/{id} ─────────────────────────────────────────

    @Test
    @WithMockUser(authorities = "READ_ALL")
    void update_returns403_whenLackingBlogWrite() throws Exception {
        mockMvc.perform(patch("/api/geschichten/{id}", UUID.randomUUID()).with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{}"))
                .andExpect(status().isForbidden());
    }

    @Test
    @WithMockUser(authorities = "BLOG_WRITE")
    void update_returns200_withBlogWrite() throws Exception {
        UUID id = UUID.randomUUID();
        when(geschichteService.update(eq(id), any(GeschichteUpdateDTO.class)))
                .thenReturn(viewStub(id, "Updated", GeschichteStatus.PUBLISHED));

        mockMvc.perform(patch("/api/geschichten/{id}", id).with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"status\":\"PUBLISHED\"}"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.status").value("PUBLISHED"));
    }

    // ─── DELETE /api/geschichten/{id} ────────────────────────────────────────

    @Test
    @WithMockUser(authorities = "READ_ALL")
    void delete_returns403_whenLackingBlogWrite() throws Exception {
        mockMvc.perform(delete("/api/geschichten/{id}", UUID.randomUUID()).with(csrf()))
                .andExpect(status().isForbidden());
    }

    @Test
    @WithMockUser(authorities = "BLOG_WRITE")
    void delete_returns204_withBlogWrite() throws Exception {
        UUID id = UUID.randomUUID();

        mockMvc.perform(delete("/api/geschichten/{id}", id).with(csrf()))
                .andExpect(status().isNoContent());

        verify(geschichteService).delete(id);
    }

    // ─── POST /api/geschichten/{id}/items ────────────────────────────────────

    @Test
    void appendItem_returns401_whenUnauthenticated() throws Exception {
        mockMvc.perform(post("/api/geschichten/{id}/items", UUID.randomUUID()).with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"note\":\"x\"}"))
                .andExpect(status().isUnauthorized());
    }

    @Test
    @WithMockUser(authorities = "READ_ALL")
    void appendItem_returns403_whenLackingBlogWrite() throws Exception {
        mockMvc.perform(post("/api/geschichten/{id}/items", UUID.randomUUID()).with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"note\":\"x\"}"))
                .andExpect(status().isForbidden());
    }

    @Test
    @WithMockUser(authorities = "BLOG_WRITE")
    void appendItem_returns201_withBlogWrite() throws Exception {
        UUID id = UUID.randomUUID();
        UUID itemId = UUID.randomUUID();
        when(journeyItemService.append(eq(id), any())).thenReturn(itemViewStub(itemId, 10, "Note"));

        mockMvc.perform(post("/api/geschichten/{id}/items", id).with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"note\":\"Note\"}"))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.id").value(itemId.toString()))
                .andExpect(jsonPath("$.position").value(10));
    }

    // ─── PATCH /api/geschichten/{id}/items/{itemId} ──────────────────────────

    @Test
    @WithMockUser(authorities = "READ_ALL")
    void updateItemNote_returns403_whenLackingBlogWrite() throws Exception {
        mockMvc.perform(patch("/api/geschichten/{id}/items/{itemId}",
                        UUID.randomUUID(), UUID.randomUUID()).with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"note\":\"x\"}"))
                .andExpect(status().isForbidden());
    }

    @Test
    @WithMockUser(authorities = "BLOG_WRITE")
    void updateItemNote_returns200_withBlogWrite() throws Exception {
        UUID id = UUID.randomUUID();
        UUID itemId = UUID.randomUUID();
        when(journeyItemService.updateNote(eq(id), eq(itemId), any()))
                .thenReturn(itemViewStub(itemId, 10, "Updated"));

        mockMvc.perform(patch("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"note\":\"Updated\"}"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.note").value("Updated"));
    }

    @Test
    @WithMockUser(authorities = "BLOG_WRITE")
    void updateItemNote_json_null_note_is_deserialized_as_empty_Optional() throws Exception {
        UUID id = UUID.randomUUID();
        UUID itemId = UUID.randomUUID();
        when(journeyItemService.updateNote(eq(id), eq(itemId), any()))
                .thenReturn(itemViewStub(itemId, 10, null));

        // Raw JSON — local objectMapper lacks JsonNullableModule
        mockMvc.perform(patch("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"note\": null}"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.note").value(nullValue()));
    }

    @Test
    @WithMockUser(authorities = "BLOG_WRITE")
    void updateItemNote_returns404_whenItemNotFound() throws Exception {
        UUID id = UUID.randomUUID();
        UUID itemId = UUID.randomUUID();
        when(journeyItemService.updateNote(eq(id), eq(itemId), any()))
                .thenThrow(DomainException.notFound(ErrorCode.JOURNEY_ITEM_NOT_FOUND, "not found"));

        mockMvc.perform(patch("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"note\":\"x\"}"))
                .andExpect(status().isNotFound())
                .andExpect(jsonPath("$.code").value("JOURNEY_ITEM_NOT_FOUND"));
    }

    // ─── DELETE /api/geschichten/{id}/items/{itemId} ─────────────────────────

    @Test
    @WithMockUser(authorities = "READ_ALL")
    void deleteItem_returns403_whenLackingBlogWrite() throws Exception {
        mockMvc.perform(delete("/api/geschichten/{id}/items/{itemId}",
                        UUID.randomUUID(), UUID.randomUUID()).with(csrf()))
                .andExpect(status().isForbidden());
    }

    @Test
    @WithMockUser(authorities = "BLOG_WRITE")
    void deleteItem_returns204_withBlogWrite() throws Exception {
        UUID id = UUID.randomUUID();
        UUID itemId = UUID.randomUUID();

        mockMvc.perform(delete("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf()))
                .andExpect(status().isNoContent());

        verify(journeyItemService).delete(id, itemId);
    }

    @Test
    @WithMockUser(authorities = "BLOG_WRITE")
    void deleteItem_returns404_whenItemNotFound() throws Exception {
        UUID id = UUID.randomUUID();
        UUID itemId = UUID.randomUUID();
        org.mockito.Mockito.doThrow(DomainException.notFound(ErrorCode.JOURNEY_ITEM_NOT_FOUND, "not found"))
                .when(journeyItemService).delete(id, itemId);

        mockMvc.perform(delete("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf()))
                .andExpect(status().isNotFound())
                .andExpect(jsonPath("$.code").value("JOURNEY_ITEM_NOT_FOUND"));
    }

    // ─── PUT /api/geschichten/{id}/items/reorder ─────────────────────────────

    @Test
    @WithMockUser(authorities = "READ_ALL")
    void reorderItems_returns403_whenLackingBlogWrite() throws Exception {
        mockMvc.perform(put("/api/geschichten/{id}/items/reorder", UUID.randomUUID()).with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"itemIds\":[]}"))
                .andExpect(status().isForbidden());
    }

    @Test
    @WithMockUser(authorities = "BLOG_WRITE")
    void reorderItems_returns200_withBlogWrite() throws Exception {
        UUID id = UUID.randomUUID();
        UUID itemId = UUID.randomUUID();
        when(journeyItemService.reorder(eq(id), any())).thenReturn(List.of(itemViewStub(itemId, 10, null)));

        mockMvc.perform(put("/api/geschichten/{id}/items/reorder", id).with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"itemIds\":[\"" + itemId + "\"]}"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$[0].id").value(itemId.toString()));
    }

    // ─── error mapping ───────────────────────────────────────────────────────

    @Test
    @WithMockUser(authorities = "BLOG_WRITE")
    void appendItem_returns409_on_position_conflict() throws Exception {
        UUID id = UUID.randomUUID();
        when(journeyItemService.append(eq(id), any()))
                .thenThrow(DomainException.conflict(ErrorCode.JOURNEY_ITEM_POSITION_CONFLICT, "conflict"));

        mockMvc.perform(post("/api/geschichten/{id}/items", id).with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"note\":\"x\"}"))
                .andExpect(status().isConflict())
                .andExpect(jsonPath("$.code").value("JOURNEY_ITEM_POSITION_CONFLICT"));
    }

    // ─── helpers ─────────────────────────────────────────────────────────────

    private JourneyItemView itemViewStub(UUID id, int position, String note) {
        return new JourneyItemView(id, position, null, note);
    }

    private GeschichteView viewStub(UUID id, String title) {
        return viewStub(id, title, GeschichteStatus.PUBLISHED);
    }

    private GeschichteView viewStub(UUID id, String title, GeschichteStatus status) {
        return new GeschichteView(id, title, "<p>x</p>",
                status, GeschichteType.STORY,
                null, new HashSet<>(), List.of(),
                LocalDateTime.now(), LocalDateTime.now(), LocalDateTime.now());
    }

    /** Concrete implementation — Mockito interface mocks are not serialized reliably by Jackson. */
    private GeschichteSummary summaryStub(String title) {
        return new GeschichteSummary() {
            public UUID getId() { return UUID.randomUUID(); }
            public String getTitle() { return title; }
            public GeschichteStatus getStatus() { return GeschichteStatus.PUBLISHED; }
            public GeschichteType getType() { return GeschichteType.STORY; }
            public AuthorSummary getAuthor() { return null; }
            public LocalDateTime getPublishedAt() { return LocalDateTime.now(); }
            public LocalDateTime getUpdatedAt() { return LocalDateTime.now(); }
            public String getBody() { return null; }
        };
    }
}
