package org.raddatz.familienarchiv.person;

import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonNameAlias;
import org.raddatz.familienarchiv.person.PersonNameAliasType;
import org.raddatz.familienarchiv.security.PermissionAspect;
import org.raddatz.familienarchiv.user.CustomUserDetailsService;
import org.raddatz.familienarchiv.document.DocumentService;
import org.raddatz.familienarchiv.person.PersonService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.raddatz.familienarchiv.security.SecurityConfig;
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
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.LocalDate;
import java.util.Collections;
import java.util.List;
import java.util.UUID;

import org.raddatz.familienarchiv.person.PersonSummaryDTO;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;

@WebMvcTest(PersonController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
class PersonControllerTest {

    @Autowired MockMvc mockMvc;

    @MockitoBean PersonService personService;
    @MockitoBean DocumentService documentService;
    @MockitoBean CustomUserDetailsService customUserDetailsService;

    // ─── GET /api/persons ─────────────────────────────────────────────────────

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

    @Test
    @WithMockUser
    void getPersons_returns403_whenMissingReadAllPermission() throws Exception {
        mockMvc.perform(get("/api/persons"))
                .andExpect(status().isForbidden());
    }

    @Test
    @WithMockUser(authorities = "READ_ALL")
    void getPersons_returns200_withEmptyPagedResult() throws Exception {
        when(personService.search(any(), eq(0), eq(50), eq(null)))
                .thenReturn(PersonSearchResult.paged(Collections.emptyList(), 0, 50, 0));
        mockMvc.perform(get("/api/persons"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.items").isArray())
                .andExpect(jsonPath("$.totalElements").value(0));
    }

    @Test
    @WithMockUser(authorities = "READ_ALL")
    void getPersons_delegatesQueryParam_toService() throws Exception {
        PersonSummaryDTO dto = mockPersonSummary("Hans", "Müller");
        when(personService.search(any(), eq(0), eq(50), eq("Hans")))
                .thenReturn(PersonSearchResult.paged(List.of(dto), 0, 50, 1));

        mockMvc.perform(get("/api/persons").param("q", "Hans"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.items[0].firstName").value("Hans"));
    }

    @Test
    @WithMockUser(authorities = "READ_ALL")
    void getPersons_passesFilterParams_toService() throws Exception {
        ArgumentCaptor<PersonFilter> filterCaptor = ArgumentCaptor.forClass(PersonFilter.class);
        when(personService.search(filterCaptor.capture(), eq(0), eq(50), eq(null)))
                .thenReturn(PersonSearchResult.paged(Collections.emptyList(), 0, 50, 0));

        mockMvc.perform(get("/api/persons")
                        .param("type", "INSTITUTION")
                        .param("familyOnly", "true")
                        .param("hasDocuments", "true")
                        .param("provisional", "false"))
                .andExpect(status().isOk());

        PersonFilter captured = filterCaptor.getValue();
        assertThat(captured.type()).isEqualTo(PersonType.INSTITUTION);
        assertThat(captured.familyOnly()).isTrue();
        assertThat(captured.hasDocuments()).isTrue();
        assertThat(captured.provisional()).isFalse();
    }

    @Test
    @WithMockUser(authorities = "READ_ALL")
    void getPersons_defaultsToReaderDefault_whenNoReviewFlag() throws Exception {
        ArgumentCaptor<PersonFilter> filterCaptor = ArgumentCaptor.forClass(PersonFilter.class);
        when(personService.search(filterCaptor.capture(), eq(0), eq(50), eq(null)))
                .thenReturn(PersonSearchResult.paged(Collections.emptyList(), 0, 50, 0));

        mockMvc.perform(get("/api/persons")).andExpect(status().isOk());

        assertThat(filterCaptor.getValue().readerDefault()).isTrue();
    }

    @Test
    @WithMockUser(authorities = "READ_ALL")
    void getPersons_dropsReaderDefault_whenReviewFlagSet() throws Exception {
        ArgumentCaptor<PersonFilter> filterCaptor = ArgumentCaptor.forClass(PersonFilter.class);
        when(personService.search(filterCaptor.capture(), eq(0), eq(50), eq(null)))
                .thenReturn(PersonSearchResult.paged(Collections.emptyList(), 0, 50, 0));

        mockMvc.perform(get("/api/persons").param("review", "true")).andExpect(status().isOk());

        assertThat(filterCaptor.getValue().readerDefault()).isFalse();
    }

    @Test
    @WithMockUser(authorities = "READ_ALL")
    void getPersons_passesPageAndSize_toService() throws Exception {
        when(personService.search(any(), eq(2), eq(25), eq(null)))
                .thenReturn(PersonSearchResult.paged(Collections.emptyList(), 2, 25, 0));

        mockMvc.perform(get("/api/persons").param("page", "2").param("size", "25"))
                .andExpect(status().isOk());

        verify(personService).search(any(), eq(2), eq(25), eq(null));
    }

    @Test
    @WithMockUser(authorities = "READ_ALL")
    void getPersons_returns400_whenSizeIsZero() throws Exception {
        mockMvc.perform(get("/api/persons").param("size", "0"))
                .andExpect(status().isBadRequest());
    }

    @Test
    @WithMockUser(authorities = "READ_ALL")
    void getPersons_returns400_whenSizeExceeds100() throws Exception {
        mockMvc.perform(get("/api/persons").param("size", "101"))
                .andExpect(status().isBadRequest());
    }

    @Test
    @WithMockUser(authorities = "READ_ALL")
    void getPersons_returns400_whenPageIsNegative() throws Exception {
        mockMvc.perform(get("/api/persons").param("page", "-1"))
                .andExpect(status().isBadRequest());
    }

    @Test
    @WithMockUser(authorities = "READ_ALL")
    void getPersons_delegatesTopByDocumentCount_whenSortGiven() throws Exception {
        PersonSummaryDTO top = mockPersonSummary("Käthe", "Raddatz");
        when(personService.findTopByDocumentCount(4)).thenReturn(List.of(top));

        mockMvc.perform(get("/api/persons").param("sort", "documentCount").param("size", "4"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.items[0].firstName").value("Käthe"));
    }

    @Test
    @WithMockUser(authorities = "READ_ALL")
    void getPersons_topByDocumentCount_isNonPaged_totalElementsEqualsReturnedCount() throws Exception {
        // The top-N dashboard path is deliberately NON-paged: it returns the complete result
        // (no further page exists), so totalElements equals the number of rows returned and
        // totalPages is 1. Pinned so nobody "fixes" it into a misleading paged total.
        when(personService.findTopByDocumentCount(50))
                .thenReturn(List.of(mockPersonSummary("Käthe", "Raddatz"),
                                    mockPersonSummary("Hans", "Müller")));

        mockMvc.perform(get("/api/persons").param("sort", "documentCount"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.items.length()").value(2))
                .andExpect(jsonPath("$.totalElements").value(2))
                .andExpect(jsonPath("$.pageNumber").value(0))
                .andExpect(jsonPath("$.pageSize").value(2))
                .andExpect(jsonPath("$.totalPages").value(1));
    }

    @Test
    @WithMockUser(authorities = "READ_ALL")
    void getPersons_topByDocumentCount_emptyResult_reportsZeroPages() throws Exception {
        when(personService.findTopByDocumentCount(50)).thenReturn(Collections.emptyList());

        mockMvc.perform(get("/api/persons").param("sort", "documentCount"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.totalElements").value(0))
                .andExpect(jsonPath("$.totalPages").value(0));
    }

    private PersonSummaryDTO mockPersonSummary(String firstName, String lastName) {
        return new PersonSummaryDTO() {
            public java.util.UUID getId() { return UUID.randomUUID(); }
            public String getTitle() { return null; }
            public String getFirstName() { return firstName; }
            public String getLastName() { return lastName; }
            public String getPersonType() { return "PERSON"; }
            public String getAlias() { return null; }
            public Integer getBirthYear() { return null; }
            public Integer getDeathYear() { return null; }
            public java.time.LocalDate getBirthDate() { return null; }
            public org.raddatz.familienarchiv.document.DatePrecision getBirthDatePrecision() {
                return org.raddatz.familienarchiv.document.DatePrecision.UNKNOWN;
            }
            public java.time.LocalDate getDeathDate() { return null; }
            public org.raddatz.familienarchiv.document.DatePrecision getDeathDatePrecision() {
                return org.raddatz.familienarchiv.document.DatePrecision.UNKNOWN;
            }
            public String getNotes() { return null; }
            public boolean isFamilyMember() { return false; }
            public boolean isProvisional() { return false; }
            public long getDocumentCount() { return 0; }
        };
    }

    // ─── GET /api/persons/{id} ────────────────────────────────────────────────

    @Test
    void getPerson_returns401_whenUnauthenticated() throws Exception {
        mockMvc.perform(get("/api/persons/{id}", UUID.randomUUID()))
                .andExpect(status().isUnauthorized());
    }

    @Test
    @WithMockUser
    void getPerson_returns403_whenMissingReadAllPermission() throws Exception {
        mockMvc.perform(get("/api/persons/{id}", UUID.randomUUID()))
                .andExpect(status().isForbidden());
    }

    @Test
    @WithMockUser(authorities = "READ_ALL")
    void getPerson_returns200_whenFound() throws Exception {
        UUID id = UUID.randomUUID();
        Person person = Person.builder().id(id).firstName("Anna").lastName("Schmidt").build();
        when(personService.getById(id)).thenReturn(person);

        mockMvc.perform(get("/api/persons/{id}", id))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.firstName").value("Anna"));
    }

    // ─── GET /api/persons/{id}/correspondents ─────────────────────────────────

    @Test
    void getCorrespondents_returns401_whenUnauthenticated() throws Exception {
        mockMvc.perform(get("/api/persons/{id}/correspondents", UUID.randomUUID()))
                .andExpect(status().isUnauthorized());
    }

    @Test
    @WithMockUser
    void getCorrespondents_returns200_withoutFilter() throws Exception {
        UUID personId = UUID.randomUUID();
        when(personService.findCorrespondents(personId, null)).thenReturn(Collections.emptyList());

        mockMvc.perform(get("/api/persons/{id}/correspondents", personId))
                .andExpect(status().isOk());
    }

    @Test
    @WithMockUser
    void getCorrespondents_returns200_withFilter() throws Exception {
        UUID personId = UUID.randomUUID();
        Person correspondent = Person.builder().id(UUID.randomUUID()).firstName("Walter").lastName("Gruyter").build();
        when(personService.findCorrespondents(personId, "Walter")).thenReturn(List.of(correspondent));

        mockMvc.perform(get("/api/persons/{id}/correspondents", personId).param("q", "Walter"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$[0].firstName").value("Walter"));
    }

    // ─── GET /api/persons/{id}/documents ──────────────────────────────────────

    @Test
    void getPersonDocuments_returns401_whenUnauthenticated() throws Exception {
        mockMvc.perform(get("/api/persons/{id}/documents", UUID.randomUUID()))
                .andExpect(status().isUnauthorized());
    }

    @Test
    @WithMockUser
    void getPersonDocuments_returns200_whenAuthenticated() throws Exception {
        UUID personId = UUID.randomUUID();
        when(documentService.getDocumentsBySender(personId)).thenReturn(Collections.emptyList());

        mockMvc.perform(get("/api/persons/{id}/documents", personId))
                .andExpect(status().isOk());
    }

    // ─── GET /api/persons/{id}/received-documents ─────────────────────────────

    @Test
    void getReceivedDocuments_returns401_whenUnauthenticated() throws Exception {
        mockMvc.perform(get("/api/persons/{id}/received-documents", UUID.randomUUID()))
                .andExpect(status().isUnauthorized());
    }

    @Test
    @WithMockUser
    void getReceivedDocuments_returns200_whenAuthenticated() throws Exception {
        UUID personId = UUID.randomUUID();
        when(documentService.getDocumentsByReceiver(personId)).thenReturn(Collections.emptyList());

        mockMvc.perform(get("/api/persons/{id}/received-documents", personId))
                .andExpect(status().isOk());
    }

    // ─── POST /api/persons ────────────────────────────────────────────────────

    @Test
    void createPerson_returns401_whenUnauthenticated() throws Exception {
        mockMvc.perform(post("/api/persons").with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
                .andExpect(status().isUnauthorized());
    }

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void createPerson_returns400_whenPersonTypeIsPerson_andFirstNameIsMissing() throws Exception {
        mockMvc.perform(post("/api/persons").with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
                .andExpect(status().isBadRequest());
    }

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void createPerson_returns400_whenPersonTypeIsPerson_andFirstNameIsBlank() throws Exception {
        mockMvc.perform(post("/api/persons").with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"firstName\":\"  \",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
                .andExpect(status().isBadRequest());
    }

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void createPerson_returns400_whenLastNameIsMissing() throws Exception {
        mockMvc.perform(post("/api/persons").with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"firstName\":\"Hans\",\"personType\":\"PERSON\"}"))
                .andExpect(status().isBadRequest());
    }

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void createPerson_returns400_whenLastNameIsBlank() throws Exception {
        mockMvc.perform(post("/api/persons").with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"firstName\":\"Hans\",\"lastName\":\"  \",\"personType\":\"PERSON\"}"))
                .andExpect(status().isBadRequest());
    }

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void createPerson_returns200_whenValid() throws Exception {
        Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
        when(personService.createPerson(any(org.raddatz.familienarchiv.person.PersonUpdateDTO.class))).thenReturn(saved);

        mockMvc.perform(post("/api/persons").with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.firstName").value("Hans"));
    }

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void createPerson_returns200_forInstitution_withoutFirstName() throws Exception {
        Person saved = Person.builder().id(UUID.randomUUID()).lastName("Verlag GmbH").build();
        when(personService.createPerson(any(org.raddatz.familienarchiv.person.PersonUpdateDTO.class))).thenReturn(saved);

        mockMvc.perform(post("/api/persons").with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"lastName\":\"Verlag GmbH\",\"personType\":\"INSTITUTION\"}"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.lastName").value("Verlag GmbH"));
    }

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void createPerson_trimsTitle_beforePersisting() throws Exception {
        ArgumentCaptor<org.raddatz.familienarchiv.person.PersonUpdateDTO> captor =
                ArgumentCaptor.forClass(org.raddatz.familienarchiv.person.PersonUpdateDTO.class);
        Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
        when(personService.createPerson(captor.capture())).thenReturn(saved);

        mockMvc.perform(post("/api/persons").with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"title\":\"  Prof.  \",\"personType\":\"PERSON\"}"))
                .andExpect(status().isOk());

        assertThat(captor.getValue().getTitle()).isEqualTo("Prof.");
    }

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void createPerson_returns400_whenPersonTypeIsSkip() throws Exception {
        when(personService.createPerson(any())).thenThrow(
                DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type"));

        mockMvc.perform(post("/api/persons").with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"lastName\":\"Müller\",\"personType\":\"SKIP\"}"))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.code").value("INVALID_PERSON_TYPE"));
    }

    // ─── PUT /api/persons/{id} ────────────────────────────────────────────────

    @Test
    void updatePerson_returns401_whenUnauthenticated() throws Exception {
        mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()).with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
                .andExpect(status().isUnauthorized());
    }

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void updatePerson_returns400_whenPersonTypeIsPerson_andFirstNameIsBlank() throws Exception {
        mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()).with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"firstName\":\"\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
                .andExpect(status().isBadRequest());
    }

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void updatePerson_returns400_whenLastNameIsNull() throws Exception {
        mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()).with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"firstName\":\"Hans\",\"personType\":\"PERSON\"}"))
                .andExpect(status().isBadRequest());
    }

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void updatePerson_returns200_whenValid() throws Exception {
        UUID id = UUID.randomUUID();
        Person updated = Person.builder().id(id).firstName("Hans").lastName("Müller").build();
        when(personService.updatePerson(eq(id), any())).thenReturn(updated);

        mockMvc.perform(put("/api/persons/{id}", id).with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.lastName").value("Müller"));
    }

    // ─── POST /api/persons/{id}/merge ─────────────────────────────────────────

    @Test
    void mergePerson_returns401_whenUnauthenticated() throws Exception {
        mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID()).with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"targetPersonId\":\"" + UUID.randomUUID() + "\"}"))
                .andExpect(status().isUnauthorized());
    }

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void mergePerson_returns400_whenTargetPersonIdIsMissing() throws Exception {
        mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID()).with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{}"))
                .andExpect(status().isBadRequest());
    }

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void mergePerson_returns400_whenTargetPersonIdIsBlank() throws Exception {
        mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID()).with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"targetPersonId\":\"  \"}"))
                .andExpect(status().isBadRequest());
    }

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void mergePerson_returns204_whenValid() throws Exception {
        UUID sourceId = UUID.randomUUID();
        UUID targetId = UUID.randomUUID();

        mockMvc.perform(post("/api/persons/{id}/merge", sourceId).with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"targetPersonId\":\"" + targetId + "\"}"))
                .andExpect(status().isNoContent());
    }

    // ─── PATCH /api/persons/{id}/confirm ──────────────────────────────────────

    @Test
    void confirmPerson_returns401_whenUnauthenticated() throws Exception {
        mockMvc.perform(patch("/api/persons/{id}/confirm", UUID.randomUUID()).with(csrf()))
                .andExpect(status().isUnauthorized());
    }

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

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void confirmPerson_returns200_andClearsProvisional() throws Exception {
        UUID id = UUID.randomUUID();
        Person confirmed = Person.builder().id(id).firstName("Bald").lastName("Bestaetigt").provisional(false).build();
        when(personService.confirmPerson(id)).thenReturn(confirmed);

        mockMvc.perform(patch("/api/persons/{id}/confirm", id).with(csrf()))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.provisional").value(false));

        verify(personService).confirmPerson(id);
    }

    // ─── DELETE /api/persons/{id} ──────────────────────────────────────────────

    @Test
    void deletePerson_returns401_whenUnauthenticated() throws Exception {
        mockMvc.perform(delete("/api/persons/{id}", UUID.randomUUID()).with(csrf()))
                .andExpect(status().isUnauthorized());
    }

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

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

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

        verify(personService).deletePerson(id);
    }

    // ─── PUT /api/persons/{id} — lastName blank branch ────────────────────────

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void updatePerson_returns400_whenLastNameIsBlank() throws Exception {
        UUID id = UUID.randomUUID();
        mockMvc.perform(put("/api/persons/{id}", id).with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"firstName\":\"Hans\",\"lastName\":\"   \",\"personType\":\"PERSON\"}"))
                .andExpect(status().isBadRequest());
    }

    // ─── Phase 2.2: POST /api/persons with full PersonUpdateDTO ───────────────

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void createPerson_returns200_withAllSixFields() throws Exception {
        UUID id = UUID.randomUUID();
        Person saved = Person.builder().id(id).firstName("Maria").lastName("Raddatz")
                .alias("Oma Maria")
                .birthDate(LocalDate.of(1901, 3, 14)).birthDatePrecision(DatePrecision.DAY)
                .deathDate(LocalDate.of(1975, 1, 1)).deathDatePrecision(DatePrecision.YEAR)
                .notes("Some notes").build();
        when(personService.createPerson(any(org.raddatz.familienarchiv.person.PersonUpdateDTO.class))).thenReturn(saved);

        mockMvc.perform(post("/api/persons").with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"firstName\":\"Maria\",\"lastName\":\"Raddatz\"," +
                                "\"alias\":\"Oma Maria\"," +
                                "\"birthDate\":\"1901-03-14\",\"birthDatePrecision\":\"DAY\"," +
                                "\"deathDate\":\"1975-01-01\",\"deathDatePrecision\":\"YEAR\"," +
                                "\"notes\":\"Some notes\",\"personType\":\"PERSON\"}"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.firstName").value("Maria"))
                .andExpect(jsonPath("$.alias").value("Oma Maria"))
                .andExpect(jsonPath("$.birthDate").value("1901-03-14"))
                .andExpect(jsonPath("$.birthDatePrecision").value("DAY"));
    }

    // ─── #773: malformed date payloads return structured 400s, not Jackson traces ──
    // Jackson rejects unknown enum values by default. Verified 2026-06-12: the only
    // DeserializationFeature hit in src/main is RestClientOcrClient's private ObjectMapper
    // (OCR HTTP client) — the Spring MVC mapper has no READ_UNKNOWN_ENUM_VALUES_* override.

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void updatePerson_returns400WithStructuredErrorCode_whenPrecisionEnumInvalid() throws Exception {
        UUID id = UUID.randomUUID();
        mockMvc.perform(put("/api/persons/{id}", id).with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"," +
                                "\"birthDate\":\"1901-03-14\",\"birthDatePrecision\":\"INVALID_VALUE\"}"))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
    }

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void updatePerson_returns400WithStructuredErrorCode_whenBirthDateNotADate() throws Exception {
        UUID id = UUID.randomUUID();
        mockMvc.perform(put("/api/persons/{id}", id).with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"," +
                                "\"birthDate\":\"not-a-date\",\"birthDatePrecision\":\"DAY\"}"))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
    }

    // ─── Phase 1.2: @Size constraints ─────────────────────────────────────────

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void updatePerson_returns400_whenNotesExceed5000Chars() throws Exception {
        String oversizedNotes = "x".repeat(5001);
        UUID id = UUID.randomUUID();
        mockMvc.perform(put("/api/persons/{id}", id).with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"notes\":\"" + oversizedNotes + "\",\"personType\":\"PERSON\"}"))
                .andExpect(status().isBadRequest());
    }

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void updatePerson_returns400_whenFirstNameExceeds100Chars() throws Exception {
        String oversizedFirstName = "x".repeat(101);
        UUID id = UUID.randomUUID();
        mockMvc.perform(put("/api/persons/{id}", id).with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"firstName\":\"" + oversizedFirstName + "\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
                .andExpect(status().isBadRequest());
    }

    // ─── Phase 1.1: @RequirePermission(WRITE_ALL) on write endpoints ──────────

    @Test
    @WithMockUser(authorities = "READ_ALL")
    void createPerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
        mockMvc.perform(post("/api/persons").with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
                .andExpect(status().isForbidden());
    }

    @Test
    @WithMockUser(authorities = "READ_ALL")
    void updatePerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
        mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()).with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
                .andExpect(status().isForbidden());
    }

    @Test
    @WithMockUser(authorities = "READ_ALL")
    void mergePerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
        mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID()).with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"targetPersonId\":\"" + UUID.randomUUID() + "\"}"))
                .andExpect(status().isForbidden());
    }

    // ─── GET /api/persons/{id}/aliases ────────────────────────────────────────

    @Test
    @WithMockUser
    void getAliases_returns200_withList() throws Exception {
        UUID personId = UUID.randomUUID();
        PersonNameAlias alias = PersonNameAlias.builder()
                .id(UUID.randomUUID()).lastName("de Gruyter").type(PersonNameAliasType.BIRTH).sortOrder(0).build();
        when(personService.getAliases(personId)).thenReturn(List.of(alias));

        mockMvc.perform(get("/api/persons/{id}/aliases", personId))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$[0].lastName").value("de Gruyter"));
    }

    // ─── POST /api/persons/{id}/aliases ──────────────────────────────────────

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void addAlias_returns200_whenValid() throws Exception {
        UUID personId = UUID.randomUUID();
        PersonNameAlias saved = PersonNameAlias.builder()
                .id(UUID.randomUUID()).lastName("de Gruyter").type(PersonNameAliasType.BIRTH).sortOrder(0).build();
        when(personService.addAlias(eq(personId), any())).thenReturn(saved);

        mockMvc.perform(post("/api/persons/{id}/aliases", personId).with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"lastName\":\"de Gruyter\",\"type\":\"BIRTH\"}"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.lastName").value("de Gruyter"));
    }

    @Test
    @WithMockUser(authorities = "READ_ALL")
    void addAlias_returns403_withoutWritePermission() throws Exception {
        mockMvc.perform(post("/api/persons/{id}/aliases", UUID.randomUUID()).with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"lastName\":\"de Gruyter\",\"type\":\"BIRTH\"}"))
                .andExpect(status().isForbidden());
    }

    // ─── DELETE /api/persons/{id}/aliases/{aliasId} ──────────────────────────

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void removeAlias_returns204_whenValid() throws Exception {
        UUID personId = UUID.randomUUID();
        UUID aliasId = UUID.randomUUID();

        mockMvc.perform(delete("/api/persons/{id}/aliases/{aliasId}", personId, aliasId).with(csrf()))
                .andExpect(status().isNoContent());

        verify(personService).removeAlias(personId, aliasId);
    }

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

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void addAlias_returns400_whenLastNameIsBlank() throws Exception {
        mockMvc.perform(post("/api/persons/{id}/aliases", UUID.randomUUID()).with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"lastName\":\"\",\"type\":\"BIRTH\"}"))
                .andExpect(status().isBadRequest());
    }

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void addAlias_returns400_whenTypeIsNull() throws Exception {
        mockMvc.perform(post("/api/persons/{id}/aliases", UUID.randomUUID()).with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"lastName\":\"de Gruyter\"}"))
                .andExpect(status().isBadRequest());
    }

    // ─── generation field validation (#689) ────────────────────────────────────

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void updatePerson_returns400_whenGenerationAboveRange() throws Exception {
        mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()).with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\","
                                + "\"personType\":\"PERSON\",\"generation\":11}"))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.code").value(ErrorCode.VALIDATION_ERROR.name()));
    }

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void updatePerson_returns400_whenGenerationBelowRange() throws Exception {
        mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()).with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\","
                                + "\"personType\":\"PERSON\",\"generation\":-1}"))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.code").value(ErrorCode.VALIDATION_ERROR.name()));
    }

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void updatePerson_returns200_whenGenerationNull() throws Exception {
        // Symmetric body assertion: the response must echo generation as null (not
        // absent), so the frontend re-hydrates the "(none)" option after a clear.
        // Without this, the in-range test below would be the only end-to-end proof
        // that the field flows through the controller.
        Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
        when(personService.updatePerson(any(), any())).thenReturn(saved);

        mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()).with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\","
                                + "\"personType\":\"PERSON\",\"generation\":null}"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.generation").value(org.hamcrest.Matchers.nullValue()));
    }

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void updatePerson_returns200_whenGenerationInRange() throws Exception {
        Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").generation(3).build();
        when(personService.updatePerson(any(), any())).thenReturn(saved);

        mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()).with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\","
                                + "\"personType\":\"PERSON\",\"generation\":3}"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.generation").value(3));
    }

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void createPerson_returns200_whenGenerationInRange() throws Exception {
        Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").generation(3).build();
        when(personService.createPerson(any(org.raddatz.familienarchiv.person.PersonUpdateDTO.class))).thenReturn(saved);

        mockMvc.perform(post("/api/persons").with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\","
                                + "\"personType\":\"PERSON\",\"generation\":3}"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.generation").value(3));
    }
}
