package org.raddatz.familienarchiv.document;

import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.raddatz.familienarchiv.document.DocumentBatchMetadataDTO;
import org.raddatz.familienarchiv.document.DocumentSearchResult;
import org.raddatz.familienarchiv.document.DocumentVersionSummary;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.user.AppUser;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentStatus;
import org.raddatz.familienarchiv.document.DocumentVersion;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.security.PermissionAspect;
import org.raddatz.familienarchiv.user.CustomUserDetailsService;
import org.raddatz.familienarchiv.document.DocumentService;
import org.raddatz.familienarchiv.document.DocumentVersionService;
import org.raddatz.familienarchiv.filestorage.FileService;
import org.raddatz.familienarchiv.user.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.raddatz.familienarchiv.security.SecurityConfig;
import org.springframework.http.MediaType;
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
import org.springframework.context.annotation.Import;
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 org.raddatz.familienarchiv.document.SearchMatchData;

import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.UUID;

import static org.assertj.core.api.Assertions.assertThat;
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.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
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.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
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(DocumentController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
class DocumentControllerTest {

    @Autowired MockMvc mockMvc;

    @MockitoBean DocumentService documentService;
    @MockitoBean DocumentVersionService documentVersionService;
    @MockitoBean FileService fileService;
    @MockitoBean UserService userService;
    @MockitoBean CustomUserDetailsService customUserDetailsService;

    // ─── GET /api/documents/search ────────────────────────────────────────────

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

    @Test
    @WithMockUser
    void search_returns200_whenAuthenticated() throws Exception {
        when(documentService.searchDocuments(any(), any(), any(), any()))
                .thenReturn(DocumentSearchResult.of(List.of()));

        mockMvc.perform(get("/api/documents/search"))
                .andExpect(status().isOk());
    }

    @Test
    @WithMockUser
    void search_undatedTrue_isReachableByAuthenticatedUser() throws Exception {
        // The read GET must stay reachable for READ_ALL users — guards against a
        // future refactor accidentally write-guarding the undated triage path (#668).
        when(documentService.searchDocuments(any(), any(), any(), any()))
                .thenReturn(DocumentSearchResult.of(List.of()));

        mockMvc.perform(get("/api/documents/search").param("undated", "true"))
                .andExpect(status().isOk());
    }

    @Test
    void search_undatedTrue_returns401_whenUnauthenticated() throws Exception {
        mockMvc.perform(get("/api/documents/search").param("undated", "true"))
                .andExpect(status().isUnauthorized());
    }

    @Test
    @WithMockUser
    void search_undatedTrue_isForwardedToServiceAsTrue() throws Exception {
        ArgumentCaptor<SearchFilters> filtersCaptor = ArgumentCaptor.forClass(SearchFilters.class);
        when(documentService.searchDocuments(any(), any(), any(), any()))
                .thenReturn(DocumentSearchResult.of(List.of()));

        mockMvc.perform(get("/api/documents/search").param("undated", "true"))
                .andExpect(status().isOk());

        verify(documentService).searchDocuments(filtersCaptor.capture(), any(), any(), any());
        assertThat(filtersCaptor.getValue().undated()).isTrue();
    }

    @Test
    @WithMockUser
    void search_withoutUndatedParam_forwardsFalseToService() throws Exception {
        ArgumentCaptor<SearchFilters> filtersCaptor = ArgumentCaptor.forClass(SearchFilters.class);
        when(documentService.searchDocuments(any(), any(), any(), any()))
                .thenReturn(DocumentSearchResult.of(List.of()));

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

        verify(documentService).searchDocuments(filtersCaptor.capture(), any(), any(), any());
        assertThat(filtersCaptor.getValue().undated()).isFalse();
    }

    @Test
    @WithMockUser
    void search_withStatusParam_passesItToService() throws Exception {
        ArgumentCaptor<SearchFilters> filtersCaptor = ArgumentCaptor.forClass(SearchFilters.class);
        when(documentService.searchDocuments(any(), any(), any(), any()))
                .thenReturn(DocumentSearchResult.of(List.of()));

        mockMvc.perform(get("/api/documents/search").param("status", "REVIEWED"))
                .andExpect(status().isOk());

        verify(documentService).searchDocuments(filtersCaptor.capture(), any(), any(), any());
        assertThat(filtersCaptor.getValue().status()).isEqualTo(DocumentStatus.REVIEWED);
    }

    @Test
    @WithMockUser
    void search_withInvalidStatus_returns400() throws Exception {
        mockMvc.perform(get("/api/documents/search").param("status", "INVALID"))
                .andExpect(status().isBadRequest());
    }

    @Test
    @WithMockUser
    void search_withInvalidDir_returns400() throws Exception {
        mockMvc.perform(get("/api/documents/search").param("dir", "INVALID"))
                .andExpect(status().isBadRequest());
    }

    @Test
    @WithMockUser
    void search_withInvalidSort_returns400() throws Exception {
        mockMvc.perform(get("/api/documents/search").param("sort", "GARBAGE"))
                .andExpect(status().isBadRequest());
    }

    @Test
    @WithMockUser
    void search_responseContainsTotalCount() throws Exception {
        when(documentService.searchDocuments(any(), any(), any(), any()))
                .thenReturn(DocumentSearchResult.of(List.of()));

        mockMvc.perform(get("/api/documents/search"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.totalElements").value(0))
                .andExpect(jsonPath("$.items").isArray());
    }

    @Test
    @WithMockUser
    void search_responseBodyItemsContainMatchData() throws Exception {
        UUID docId = UUID.randomUUID();
        var matchData = new SearchMatchData(
                "Er schrieb einen langen Brief", List.of(), false, List.of(), List.of(), List.of(), null, List.of());
        when(documentService.searchDocuments(any(), any(), any(), any()))
                .thenReturn(DocumentSearchResult.of(List.of(new DocumentListItem(
                        docId, "Brief an Anna", "brief.pdf", null, null,
                        DatePrecision.UNKNOWN, null, null,
                        List.of(), List.of(), null, null, null, null,
                        0, List.of(), matchData,
                        LocalDateTime.of(2026, 1, 15, 10, 0), LocalDateTime.of(2026, 1, 15, 10, 0)))));

        mockMvc.perform(get("/api/documents/search").param("q", "Brief"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.items").isArray())
                .andExpect(jsonPath("$.items[0].matchData.transcriptionSnippet")
                        .value("Er schrieb einen langen Brief"));
    }

    @Test
    @WithMockUser
    void search_returns_flat_item_with_id_and_without_sensitive_fields() throws Exception {
        UUID docId = UUID.randomUUID();
        var matchData = new SearchMatchData(null, List.of(), false, List.of(), List.of(), List.of(), null, List.of());
        when(documentService.searchDocuments(any(), any(), any(), any()))
                .thenReturn(DocumentSearchResult.of(List.of(new DocumentListItem(
                        docId, "Brief an Anna", "brief.pdf", null, null,
                        DatePrecision.UNKNOWN, null, null,
                        List.of(), List.of(), null, null, null, null,
                        0, List.of(), matchData,
                        LocalDateTime.of(2026, 1, 15, 10, 0), LocalDateTime.of(2026, 1, 15, 10, 0)))));

        mockMvc.perform(get("/api/documents/search"))
                .andExpect(status().isOk())
                // flat id field present at top of item (not nested under $.items[0].document.id)
                .andExpect(jsonPath("$.items[0].id").value(docId.toString()))
                // sensitive storage fields must never appear in list response
                .andExpect(jsonPath("$.items[0].transcription").doesNotExist())
                .andExpect(jsonPath("$.items[0].filePath").doesNotExist())
                .andExpect(jsonPath("$.items[0].fileHash").doesNotExist());
    }

    // ─── /api/documents/search pagination ─────────────────────────────────────

    @Test
    @WithMockUser
    void search_responseExposesPagingFields() throws Exception {
        when(documentService.searchDocuments(any(), any(), any(), any()))
                .thenReturn(DocumentSearchResult.of(List.of()));

        mockMvc.perform(get("/api/documents/search"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.pageNumber").exists())
                .andExpect(jsonPath("$.pageSize").exists())
                .andExpect(jsonPath("$.totalPages").exists())
                .andExpect(jsonPath("$.totalElements").exists());
    }

    @Test
    @WithMockUser
    void search_returns400_whenSizeExceedsMax() throws Exception {
        // Locks @Validated on the controller — removing it silently reopens the
        // DoS window where a client could request all 1500 docs + enrichment.
        mockMvc.perform(get("/api/documents/search").param("size", "101"))
                .andExpect(status().isBadRequest());
    }

    @Test
    @WithMockUser
    void search_returns400_whenSizeBelowMin() throws Exception {
        mockMvc.perform(get("/api/documents/search").param("size", "0"))
                .andExpect(status().isBadRequest());
    }

    @Test
    @WithMockUser
    void search_returns400_whenPageNegative() throws Exception {
        mockMvc.perform(get("/api/documents/search").param("page", "-1"))
                .andExpect(status().isBadRequest());
    }

    @Test
    @WithMockUser
    void search_returns400_whenPageAboveMax() throws Exception {
        // Guards against page * size overflow into negative SQL OFFSET
        mockMvc.perform(get("/api/documents/search").param("page", "200000"))
                .andExpect(status().isBadRequest());
    }

    @Test
    @WithMockUser
    void search_passesPageRequestToService() throws Exception {
        when(documentService.searchDocuments(any(), any(), any(), any()))
                .thenReturn(DocumentSearchResult.of(List.of()));

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

        org.mockito.ArgumentCaptor<org.springframework.data.domain.Pageable> captor =
                org.mockito.ArgumentCaptor.forClass(org.springframework.data.domain.Pageable.class);
        verify(documentService).searchDocuments(any(), any(), any(), captor.capture());
        org.springframework.data.domain.Pageable pageable = captor.getValue();
        org.assertj.core.api.Assertions.assertThat(pageable.getPageNumber()).isEqualTo(2);
        org.assertj.core.api.Assertions.assertThat(pageable.getPageSize()).isEqualTo(25);
    }

    // ─── POST /api/documents ─────────────────────────────────────────────────

    @Test
    void createDocument_returns401_whenUnauthenticated() throws Exception {
        mockMvc.perform(multipart("/api/documents").with(csrf()))
                .andExpect(status().isUnauthorized());
    }

    @Test
    @WithMockUser
    void createDocument_returns403_whenMissingWritePermission() throws Exception {
        mockMvc.perform(multipart("/api/documents").with(csrf()))
                .andExpect(status().isForbidden());
    }

    @Test
    @WithMockUser(authorities = "READ_ALL")
    void createDocument_returns403_forReaderOnly() throws Exception {
        mockMvc.perform(multipart("/api/documents").with(csrf()))
                .andExpect(status().isForbidden());
    }

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void createDocument_returns200_whenHasWritePermission() throws Exception {
        Document doc = Document.builder()
                .id(UUID.randomUUID())
                .title("Test")
                .originalFilename("test.pdf")
                .build();
        when(documentService.createDocument(any(), any())).thenReturn(doc);

        mockMvc.perform(multipart("/api/documents").with(csrf()))
                .andExpect(status().isOk());
    }

    // ─── PUT /api/documents/{id} ─────────────────────────────────────────────

    @Test
    void updateDocument_returns401_whenUnauthenticated() throws Exception {
        mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID())
                        .with(req -> { req.setMethod("PUT"); return req; }).with(csrf()))
                .andExpect(status().isUnauthorized());
    }

    @Test
    @WithMockUser
    void updateDocument_returns403_whenMissingWritePermission() throws Exception {
        mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID())
                        .with(req -> { req.setMethod("PUT"); return req; }).with(csrf()))
                .andExpect(status().isForbidden());
    }

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void updateDocument_returns200_whenHasWritePermission() throws Exception {
        UUID id = UUID.randomUUID();
        Document doc = Document.builder()
                .id(id)
                .title("Updated")
                .originalFilename("test.pdf")
                .build();
        when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
        when(documentService.updateDocument(any(), any(), any(), any())).thenReturn(doc);

        mockMvc.perform(multipart("/api/documents/" + id)
                        .with(req -> { req.setMethod("PUT"); return req; }).with(csrf()))
                .andExpect(status().isOk());
    }

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void updateDocument_bindsPrecisionFormFields_toDTO() throws Exception {
        // Pins the wire contract: the edit form's metaDatePrecision / metaDateEnd /
        // metaDateRaw multipart field names must bind to DocumentUpdateDTO. A rename
        // on either side silently drops the precision edit; this captures the DTO.
        UUID id = UUID.randomUUID();
        Document doc = Document.builder().id(id).title("Brief").originalFilename("brief.pdf").build();
        when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());

        org.mockito.ArgumentCaptor<DocumentUpdateDTO> captor =
                org.mockito.ArgumentCaptor.forClass(DocumentUpdateDTO.class);
        when(documentService.updateDocument(eq(id), captor.capture(), any(), any())).thenReturn(doc);

        mockMvc.perform(multipart("/api/documents/" + id)
                        .param("metaDatePrecision", "RANGE")
                        .param("metaDateEnd", "1917-01-11")
                        .param("metaDateRaw", "10.–11. Januar 1917")
                        .with(req -> { req.setMethod("PUT"); return req; }).with(csrf()))
                .andExpect(status().isOk());

        DocumentUpdateDTO bound = captor.getValue();
        org.assertj.core.api.Assertions.assertThat(bound.getMetaDatePrecision()).isEqualTo(DatePrecision.RANGE);
        org.assertj.core.api.Assertions.assertThat(bound.getMetaDateEnd())
                .isEqualTo(java.time.LocalDate.of(1917, 1, 11));
        org.assertj.core.api.Assertions.assertThat(bound.getMetaDateRaw()).isEqualTo("10.–11. Januar 1917");
    }

    // ─── DELETE /api/documents/{id} ──────────────────────────────────────────

    @Test
    void deleteDocument_returns401_whenUnauthenticated() throws Exception {
        mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
                        .delete("/api/documents/" + UUID.randomUUID()).with(csrf()))
                .andExpect(status().isUnauthorized());
    }

    @Test
    @WithMockUser
    void deleteDocument_returns403_whenMissingWritePermission() throws Exception {
        mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
                        .delete("/api/documents/" + UUID.randomUUID()).with(csrf()))
                .andExpect(status().isForbidden());
    }

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void deleteDocument_returns204_whenHasWritePermission() throws Exception {
        UUID id = UUID.randomUUID();
        when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
        mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
                        .delete("/api/documents/" + id).with(csrf()))
                .andExpect(status().isNoContent());
    }

    // ─── POST /api/documents/quick-upload ────────────────────────────────────

    @Test
    void quickUpload_returns401_whenUnauthenticated() throws Exception {
        mockMvc.perform(multipart("/api/documents/quick-upload").with(csrf()))
                .andExpect(status().isUnauthorized());
    }

    @Test
    @WithMockUser
    void quickUpload_returns403_whenMissingWritePermission() throws Exception {
        mockMvc.perform(multipart("/api/documents/quick-upload").with(csrf()))
                .andExpect(status().isForbidden());
    }

    @Test
    @WithMockUser(authorities = "READ_ALL")
    void quickUpload_returns403_forReaderOnly() throws Exception {
        mockMvc.perform(multipart("/api/documents/quick-upload").with(csrf()))
                .andExpect(status().isForbidden());
    }

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void quickUpload_returns200_withValidPdfFile() throws Exception {
        Document doc = Document.builder()
                .id(UUID.randomUUID()).title("scan001").originalFilename("scan001.pdf").build();
        when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
        when(documentService.storeDocument(any(), any()))
                .thenReturn(new DocumentService.StoreResult(doc, true));

        org.springframework.mock.web.MockMultipartFile file =
                new org.springframework.mock.web.MockMultipartFile("files", "scan001.pdf", "application/pdf", new byte[]{1});

        mockMvc.perform(multipart("/api/documents/quick-upload").file(file).with(csrf()))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.created[0].title").value("scan001"))
                .andExpect(jsonPath("$.updated").isEmpty())
                .andExpect(jsonPath("$.errors").isEmpty());
    }

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void quickUpload_placesDocumentInUpdated_whenFilenameAlreadyExists() throws Exception {
        Document existing = Document.builder()
                .id(UUID.randomUUID()).title("Alter Brief").originalFilename("scan001.pdf").build();
        when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
        when(documentService.storeDocument(any(), any()))
                .thenReturn(new DocumentService.StoreResult(existing, false));

        org.springframework.mock.web.MockMultipartFile file =
                new org.springframework.mock.web.MockMultipartFile("files", "scan001.pdf", "application/pdf", new byte[]{1});

        mockMvc.perform(multipart("/api/documents/quick-upload").file(file).with(csrf()))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.created").isEmpty())
                .andExpect(jsonPath("$.updated[0].title").value("Alter Brief"))
                .andExpect(jsonPath("$.errors").isEmpty());
    }

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void quickUpload_skipsUnsupportedFileType_andReturnsError() throws Exception {
        when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
        org.springframework.mock.web.MockMultipartFile file =
                new org.springframework.mock.web.MockMultipartFile("files", "report.docx",
                        "application/vnd.openxmlformats-officedocument.wordprocessingml.document", new byte[]{1});

        mockMvc.perform(multipart("/api/documents/quick-upload").file(file).with(csrf()))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.created").isEmpty())
                .andExpect(jsonPath("$.errors[0].filename").value("report.docx"))
                .andExpect(jsonPath("$.errors[0].code").value("UNSUPPORTED_FILE_TYPE"));
    }

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

    @Test
    @WithMockUser
    void getDocumentFile_returns404_whenDocHasNoFilePath() throws Exception {
        UUID id = UUID.randomUUID();
        Document doc = Document.builder().id(id).title("Brief").build(); // filePath == null
        when(documentService.getDocumentById(id)).thenReturn(doc);

        mockMvc.perform(get("/api/documents/" + id + "/file"))
                .andExpect(status().isNotFound());
    }

    @Test
    @WithMockUser
    void getDocumentFile_returns200_withContentTypeFromDoc() throws Exception {
        UUID id = UUID.randomUUID();
        Document doc = Document.builder().id(id).title("Brief")
                .filePath("docs/brief.pdf").contentType("application/pdf")
                .originalFilename("brief.pdf").build();
        when(documentService.getDocumentById(id)).thenReturn(doc);
        java.io.InputStream stream = new java.io.ByteArrayInputStream(new byte[]{1, 2, 3});
        when(fileService.downloadFile("docs/brief.pdf"))
                .thenReturn(new FileService.S3FileDownload(
                        new org.springframework.core.io.InputStreamResource(stream), "application/octet-stream"));

        mockMvc.perform(get("/api/documents/" + id + "/file"))
                .andExpect(status().isOk());
    }

    @Test
    @WithMockUser
    void getDocumentFile_returns200_withContentTypeFromStorage_whenDocContentTypeIsBlank() throws Exception {
        UUID id = UUID.randomUUID();
        Document doc = Document.builder().id(id).title("Brief")
                .filePath("docs/brief.pdf").contentType("  ") // blank → falls back to storage type
                .originalFilename("brief.pdf").build();
        when(documentService.getDocumentById(id)).thenReturn(doc);
        java.io.InputStream stream = new java.io.ByteArrayInputStream(new byte[]{1, 2, 3});
        when(fileService.downloadFile("docs/brief.pdf"))
                .thenReturn(new FileService.S3FileDownload(
                        new org.springframework.core.io.InputStreamResource(stream), "application/pdf"));

        mockMvc.perform(get("/api/documents/" + id + "/file"))
                .andExpect(status().isOk());
    }

    @Test
    @WithMockUser
    void getDocumentFile_returns404_whenStorageFileNotFound() throws Exception {
        UUID id = UUID.randomUUID();
        Document doc = Document.builder().id(id).title("Brief")
                .filePath("docs/missing.pdf").contentType("application/pdf")
                .originalFilename("missing.pdf").build();
        when(documentService.getDocumentById(id)).thenReturn(doc);
        when(fileService.downloadFile("docs/missing.pdf"))
                .thenThrow(new FileService.StorageFileNotFoundException("not found"));

        mockMvc.perform(get("/api/documents/" + id + "/file"))
                .andExpect(status().isNotFound());
    }

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

    @Test
    void getDocumentThumbnail_returns401_whenUnauthenticated() throws Exception {
        mockMvc.perform(get("/api/documents/" + UUID.randomUUID() + "/thumbnail"))
                .andExpect(status().isUnauthorized());
    }

    @Test
    @WithMockUser
    void getDocumentThumbnail_returns404_whenDocHasNoThumbnail() throws Exception {
        UUID id = UUID.randomUUID();
        Document doc = Document.builder().id(id).title("t").originalFilename("f.pdf").build();
        when(documentService.getDocumentById(id)).thenReturn(doc);

        mockMvc.perform(get("/api/documents/" + id + "/thumbnail"))
                .andExpect(status().isNotFound());
    }

    @Test
    @WithMockUser
    void getDocumentThumbnail_returns200_withPrivateCacheHeader() throws Exception {
        UUID id = UUID.randomUUID();
        Document doc = Document.builder().id(id).title("t").originalFilename("f.pdf")
                .thumbnailKey("thumbnails/" + id + ".jpg").build();
        when(documentService.getDocumentById(id)).thenReturn(doc);
        java.io.InputStream stream = new java.io.ByteArrayInputStream(new byte[]{(byte) 0xFF, (byte) 0xD8, (byte) 0xFF});
        when(fileService.downloadFile("thumbnails/" + id + ".jpg"))
                .thenReturn(new FileService.S3FileDownload(
                        new org.springframework.core.io.InputStreamResource(stream), "image/jpeg"));

        mockMvc.perform(get("/api/documents/" + id + "/thumbnail"))
                .andExpect(status().isOk())
                .andExpect(header().string("Content-Type", "image/jpeg"))
                .andExpect(header().string("Cache-Control",
                        org.hamcrest.Matchers.containsString("private")))
                .andExpect(header().string("Cache-Control",
                        org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString("public"))))
                .andExpect(header().string("Cache-Control",
                        org.hamcrest.Matchers.containsString("immutable")));
    }

    @Test
    @WithMockUser
    void getDocumentThumbnail_returns404_whenStorageObjectMissing() throws Exception {
        UUID id = UUID.randomUUID();
        Document doc = Document.builder().id(id).title("t").originalFilename("f.pdf")
                .thumbnailKey("thumbnails/" + id + ".jpg").build();
        when(documentService.getDocumentById(id)).thenReturn(doc);
        when(fileService.downloadFile("thumbnails/" + id + ".jpg"))
                .thenThrow(new FileService.StorageFileNotFoundException("not found"));

        mockMvc.perform(get("/api/documents/" + id + "/thumbnail"))
                .andExpect(status().isNotFound());
    }

    // ─── POST /api/documents/quick-upload — null/empty files ─────────────────

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void quickUpload_returnsEmptyResult_whenNoFilesPartProvided() throws Exception {
        mockMvc.perform(multipart("/api/documents/quick-upload").with(csrf()))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.created").isEmpty())
                .andExpect(jsonPath("$.updated").isEmpty())
                .andExpect(jsonPath("$.errors").isEmpty());
    }

    // ─── GET /api/documents/incomplete-count ─────────────────────────────────

    @Test
    void getIncompleteCount_returns401_whenUnauthenticated() throws Exception {
        mockMvc.perform(get("/api/documents/incomplete-count"))
                .andExpect(status().isUnauthorized());
    }

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void getIncompleteCount_returns200_withCount() throws Exception {
        when(documentService.getIncompleteCount()).thenReturn(3L);

        mockMvc.perform(get("/api/documents/incomplete-count"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.count").value(3));
    }

    @Test
    @WithMockUser(authorities = "READ_ALL")
    void getIncompleteCount_returns403_forReaderOnly() throws Exception {
        mockMvc.perform(get("/api/documents/incomplete-count"))
                .andExpect(status().isForbidden());
    }

    // ─── GET /api/documents/incomplete ───────────────────────────────────────

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

    @Test
    @WithMockUser(authorities = {"WRITE_ALL"})
    void getIncomplete_returns200_forWriter_withDTOList() throws Exception {
        UUID id = UUID.randomUUID();
        java.time.LocalDateTime uploadedAt = java.time.LocalDateTime.of(2026, 4, 20, 12, 0);
        var dto = new org.raddatz.familienarchiv.document.IncompleteDocumentDTO(id, "Unvollständig", uploadedAt);
        when(documentService.findIncompleteDocuments(anyInt())).thenReturn(List.of(dto));

        mockMvc.perform(get("/api/documents/incomplete"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$[0].id").value(id.toString()))
                .andExpect(jsonPath("$[0].title").value("Unvollständig"))
                .andExpect(jsonPath("$[0].uploadedAt").exists());
    }

    @Test
    @WithMockUser(authorities = "READ_ALL")
    void getIncomplete_returns403_forReaderOnly() throws Exception {
        mockMvc.perform(get("/api/documents/incomplete"))
                .andExpect(status().isForbidden());
    }

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void getIncomplete_capsSizeAt200() throws Exception {
        when(documentService.findIncompleteDocuments(anyInt())).thenReturn(List.of());

        mockMvc.perform(get("/api/documents/incomplete").param("size", "9999"))
                .andExpect(status().isOk());

        verify(documentService).findIncompleteDocuments(200);
    }

    // ─── GET /api/documents/incomplete/next ──────────────────────────────────

    @Test
    void getNextIncomplete_returns401_whenUnauthenticated() throws Exception {
        mockMvc.perform(get("/api/documents/incomplete/next")
                        .param("excludeId", UUID.randomUUID().toString()))
                .andExpect(status().isUnauthorized());
    }

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void getNextIncomplete_returns200_whenNextExists() throws Exception {
        UUID excludeId = UUID.randomUUID();
        Document next = Document.builder()
                .id(UUID.randomUUID()).title("Nächster").originalFilename("next.pdf").build();
        when(documentService.findNextIncompleteDocument(excludeId)).thenReturn(Optional.of(next));

        mockMvc.perform(get("/api/documents/incomplete/next")
                        .param("excludeId", excludeId.toString()))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.title").value("Nächster"));
    }

    @Test
    @WithMockUser(authorities = "READ_ALL")
    void getNextIncomplete_returns403_forReaderOnly() throws Exception {
        mockMvc.perform(get("/api/documents/incomplete/next")
                        .param("excludeId", UUID.randomUUID().toString()))
                .andExpect(status().isForbidden());
    }

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void getNextIncomplete_returns204_whenNoneRemain() throws Exception {
        UUID excludeId = UUID.randomUUID();
        when(documentService.findNextIncompleteDocument(excludeId)).thenReturn(Optional.empty());

        mockMvc.perform(get("/api/documents/incomplete/next")
                        .param("excludeId", excludeId.toString()))
                .andExpect(status().isNoContent());
    }

    // ─── GET /api/documents/recent-activity (removed — superseded by dashboard)

    @Test
    @WithMockUser
    void getRecentActivity_endpointRemoved() throws Exception {
        // The path hits /{id} and fails UUID conversion — not a 200 anymore
        mockMvc.perform(get("/api/documents/recent-activity"))
                .andExpect(status().is4xxClientError());
    }

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

    @Test
    void getVersions_returns401_whenUnauthenticated() throws Exception {
        mockMvc.perform(get("/api/documents/" + UUID.randomUUID() + "/versions"))
                .andExpect(status().isUnauthorized());
    }

    @Test
    @WithMockUser
    void getVersions_returns200_whenAuthenticated() throws Exception {
        UUID docId = UUID.randomUUID();
        DocumentVersionSummary summary = new DocumentVersionSummary(
                UUID.randomUUID(), LocalDateTime.now(), "Emma Müller", List.of("title"));
        when(documentVersionService.getSummaries(docId)).thenReturn(List.of(summary));

        mockMvc.perform(get("/api/documents/" + docId + "/versions"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$[0].editorName").value("Emma Müller"));
    }

    // ─── PATCH /api/documents/{id}/training-labels ───────────────────────────

    @Test
    void patchTrainingLabels_returns401_whenUnauthenticated() throws Exception {
        mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/training-labels").with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"label\":\"KURRENT_RECOGNITION\",\"enrolled\":true}"))
                .andExpect(status().isUnauthorized());
    }

    @Test
    @WithMockUser
    void patchTrainingLabels_returns403_whenMissingWritePermission() throws Exception {
        mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/training-labels").with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"label\":\"KURRENT_RECOGNITION\",\"enrolled\":true}"))
                .andExpect(status().isForbidden());
    }

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void patchTrainingLabels_returns204_whenAddingLabel() throws Exception {
        UUID id = UUID.randomUUID();
        mockMvc.perform(patch("/api/documents/" + id + "/training-labels").with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"label\":\"KURRENT_RECOGNITION\",\"enrolled\":true}"))
                .andExpect(status().isNoContent());

        verify(documentService).addTrainingLabel(eq(id), any());
    }

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void patchTrainingLabels_returns204_whenRemovingLabel() throws Exception {
        UUID id = UUID.randomUUID();
        mockMvc.perform(patch("/api/documents/" + id + "/training-labels").with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"label\":\"KURRENT_SEGMENTATION\",\"enrolled\":false}"))
                .andExpect(status().isNoContent());

        verify(documentService).removeTrainingLabel(eq(id), any());
    }

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void patchTrainingLabels_returns400_whenUnknownLabel() throws Exception {
        mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/training-labels").with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"label\":\"UNKNOWN_GARBAGE\",\"enrolled\":true}"))
                .andExpect(status().isBadRequest());
    }

    // ─── POST /api/documents/{id}/file ───────────────────────────────────────

    @Test
    @WithMockUser
    void attachFile_returns403_whenMissingWritePermission() throws Exception {
        org.springframework.mock.web.MockMultipartFile file =
                new org.springframework.mock.web.MockMultipartFile("file", "brief.pdf", "application/pdf", new byte[]{1});

        mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID() + "/file").file(file).with(csrf()))
                .andExpect(status().isForbidden());
    }

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void attachFile_returns200_withUpdatedDocument_whenHasWritePermission() throws Exception {
        UUID id = UUID.randomUUID();
        Document doc = Document.builder()
                .id(id).title("Brief").originalFilename("brief.pdf")
                .filePath("docs/brief.pdf").status(DocumentStatus.UPLOADED).build();
        when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
        when(documentService.attachFile(eq(id), any(), any())).thenReturn(doc);

        org.springframework.mock.web.MockMultipartFile file =
                new org.springframework.mock.web.MockMultipartFile("file", "brief.pdf", "application/pdf", new byte[]{1});

        mockMvc.perform(multipart("/api/documents/" + id + "/file").file(file).with(csrf()))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.id").value(id.toString()))
                .andExpect(jsonPath("$.status").value("UPLOADED"));
    }

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void attachFile_returns400_whenContentTypeIsNotWhitelisted() throws Exception {
        org.springframework.mock.web.MockMultipartFile htmlFile =
                new org.springframework.mock.web.MockMultipartFile(
                        "file", "evil.html", "text/html", "<script>alert(1)</script>".getBytes());

        mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID() + "/file").file(htmlFile).with(csrf()))
                .andExpect(status().isBadRequest());
    }

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void attachFile_returns404_whenDocumentDoesNotExist() throws Exception {
        UUID id = UUID.randomUUID();
        when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
        when(documentService.attachFile(eq(id), any(), any()))
                .thenThrow(DomainException.notFound(
                        ErrorCode.DOCUMENT_NOT_FOUND,
                        "Document not found: " + id));

        org.springframework.mock.web.MockMultipartFile file =
                new org.springframework.mock.web.MockMultipartFile("file", "brief.pdf", "application/pdf", new byte[]{1});

        mockMvc.perform(multipart("/api/documents/" + id + "/file").file(file).with(csrf()))
                .andExpect(status().isNotFound());
    }

    // ─── GET /api/documents/{id}/versions/{versionId} ────────────────────────

    @Test
    void getVersion_returns401_whenUnauthenticated() throws Exception {
        mockMvc.perform(get("/api/documents/" + UUID.randomUUID() + "/versions/" + UUID.randomUUID()))
                .andExpect(status().isUnauthorized());
    }

    @Test
    @WithMockUser
    void getVersion_returns200_whenAuthenticated() throws Exception {
        UUID docId = UUID.randomUUID();
        UUID versionId = UUID.randomUUID();
        DocumentVersion version = DocumentVersion.builder()
                .id(versionId).documentId(docId).savedAt(LocalDateTime.now())
                .editorName("Otto").snapshot("{\"title\":\"Brief\"}").changedFields("[]").build();
        when(documentVersionService.getVersion(docId, versionId)).thenReturn(version);

        mockMvc.perform(get("/api/documents/" + docId + "/versions/" + versionId))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.editorName").value("Otto"));
    }

    // ─── POST /api/documents/quick-upload — metadata part ────────────────────

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void quickUpload_withMetadata_appliesSharedFieldsToAllCreatedDocuments() throws Exception {
        UUID senderId = UUID.randomUUID();
        Person sender = Person.builder().id(senderId).lastName("Müller").build();

        Document doc1 = Document.builder().id(UUID.randomUUID()).title("Brief 1").originalFilename("a.pdf").sender(sender).build();
        Document doc2 = Document.builder().id(UUID.randomUUID()).title("Brief 2").originalFilename("b.pdf").sender(sender).build();
        Document doc3 = Document.builder().id(UUID.randomUUID()).title("Brief 3").originalFilename("c.pdf").sender(sender).build();

        when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
        when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(0), any()))
                .thenReturn(new DocumentService.StoreResult(doc1, true));
        when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(1), any()))
                .thenReturn(new DocumentService.StoreResult(doc2, true));
        when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(2), any()))
                .thenReturn(new DocumentService.StoreResult(doc3, true));

        org.springframework.mock.web.MockMultipartFile f1 =
                new org.springframework.mock.web.MockMultipartFile("files", "a.pdf", "application/pdf", new byte[]{1});
        org.springframework.mock.web.MockMultipartFile f2 =
                new org.springframework.mock.web.MockMultipartFile("files", "b.pdf", "application/pdf", new byte[]{1});
        org.springframework.mock.web.MockMultipartFile f3 =
                new org.springframework.mock.web.MockMultipartFile("files", "c.pdf", "application/pdf", new byte[]{1});
        org.springframework.mock.web.MockMultipartFile metadata =
                new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
                        ("{\"senderId\":\"" + senderId + "\"}").getBytes());

        mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(f3).file(metadata).with(csrf()))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.created.length()").value(3))
                .andExpect(jsonPath("$.created[0].sender.id").value(senderId.toString()))
                .andExpect(jsonPath("$.created[1].sender.id").value(senderId.toString()))
                .andExpect(jsonPath("$.created[2].sender.id").value(senderId.toString()))
                .andExpect(jsonPath("$.updated").isEmpty())
                .andExpect(jsonPath("$.errors").isEmpty());
    }

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void quickUpload_withMetadata_appliesSharedFieldsToUpdatedDocuments() throws Exception {
        UUID senderId = UUID.randomUUID();
        Person sender = Person.builder().id(senderId).lastName("Müller").build();
        Document existing = Document.builder().id(UUID.randomUUID()).title("Alt").originalFilename("alt.pdf").sender(sender).build();

        when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
        when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(0), any()))
                .thenReturn(new DocumentService.StoreResult(existing, false));

        org.springframework.mock.web.MockMultipartFile file =
                new org.springframework.mock.web.MockMultipartFile("files", "alt.pdf", "application/pdf", new byte[]{1});
        org.springframework.mock.web.MockMultipartFile metadata =
                new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
                        ("{\"senderId\":\"" + senderId + "\"}").getBytes());

        mockMvc.perform(multipart("/api/documents/quick-upload").file(file).file(metadata).with(csrf()))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.created").isEmpty())
                .andExpect(jsonPath("$.updated[0].sender.id").value(senderId.toString()))
                .andExpect(jsonPath("$.errors").isEmpty());
    }

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void quickUpload_withMetadata_mapsTitlesByIndex() throws Exception {
        Document docA = Document.builder().id(UUID.randomUUID()).title("Alpha").originalFilename("a.pdf").build();
        Document docB = Document.builder().id(UUID.randomUUID()).title("Beta").originalFilename("b.pdf").build();
        Document docC = Document.builder().id(UUID.randomUUID()).title("Gamma").originalFilename("c.pdf").build();

        when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
        when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(0), any()))
                .thenReturn(new DocumentService.StoreResult(docA, true));
        when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(1), any()))
                .thenReturn(new DocumentService.StoreResult(docB, true));
        when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(2), any()))
                .thenReturn(new DocumentService.StoreResult(docC, true));

        org.springframework.mock.web.MockMultipartFile f1 =
                new org.springframework.mock.web.MockMultipartFile("files", "a.pdf", "application/pdf", new byte[]{1});
        org.springframework.mock.web.MockMultipartFile f2 =
                new org.springframework.mock.web.MockMultipartFile("files", "b.pdf", "application/pdf", new byte[]{1});
        org.springframework.mock.web.MockMultipartFile f3 =
                new org.springframework.mock.web.MockMultipartFile("files", "c.pdf", "application/pdf", new byte[]{1});
        org.springframework.mock.web.MockMultipartFile metadata =
                new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
                        "{\"titles\":[\"Alpha\",\"Beta\",\"Gamma\"]}".getBytes());

        mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(f3).file(metadata).with(csrf()))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.created[0].title").value("Alpha"))
                .andExpect(jsonPath("$.created[1].title").value("Beta"))
                .andExpect(jsonPath("$.created[2].title").value("Gamma"));
    }

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void quickUpload_withMetadata_rejects400_whenTitlesSizeExceedsFilesSize() throws Exception {
        when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
        org.mockito.Mockito.doThrow(
                org.raddatz.familienarchiv.exception.DomainException.badRequest(
                        org.raddatz.familienarchiv.exception.ErrorCode.VALIDATION_ERROR, "titles count must not exceed files count"))
                .when(documentService).validateBatch(eq(2), any());

        org.springframework.mock.web.MockMultipartFile f1 =
                new org.springframework.mock.web.MockMultipartFile("files", "a.pdf", "application/pdf", new byte[]{1});
        org.springframework.mock.web.MockMultipartFile f2 =
                new org.springframework.mock.web.MockMultipartFile("files", "b.pdf", "application/pdf", new byte[]{1});
        org.springframework.mock.web.MockMultipartFile metadata =
                new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
                        "{\"titles\":[\"A\",\"B\",\"C\"]}".getBytes());

        mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(metadata).with(csrf()))
                .andExpect(status().isBadRequest());
    }

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void quickUpload_withMetadata_tagNamesJsonArray_parsedCorrectly() throws Exception {
        Document doc = Document.builder().id(UUID.randomUUID()).title("brief").originalFilename("brief.pdf").build();
        when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());

        org.mockito.ArgumentCaptor<DocumentBatchMetadataDTO> captor =
                org.mockito.ArgumentCaptor.forClass(DocumentBatchMetadataDTO.class);
        when(documentService.storeDocumentWithBatchMetadata(any(), captor.capture(), anyInt(), any()))
                .thenReturn(new DocumentService.StoreResult(doc, true));

        org.springframework.mock.web.MockMultipartFile file =
                new org.springframework.mock.web.MockMultipartFile("files", "brief.pdf", "application/pdf", new byte[]{1});
        org.springframework.mock.web.MockMultipartFile metadata =
                new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
                        "{\"tagNames\":[\"Briefwechsel\",\"Krieg\"]}".getBytes());

        mockMvc.perform(multipart("/api/documents/quick-upload").file(file).file(metadata).with(csrf()))
                .andExpect(status().isOk());

        org.assertj.core.api.Assertions.assertThat(captor.getValue().getTagNames())
                .containsExactly("Briefwechsel", "Krieg");
    }

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void quickUpload_returns400_whenBatchExceedsCap() throws Exception {
        when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
        org.mockito.Mockito.doThrow(
                org.raddatz.familienarchiv.exception.DomainException.badRequest(
                        org.raddatz.familienarchiv.exception.ErrorCode.BATCH_TOO_LARGE, "Batch exceeds maximum of 50 files per request"))
                .when(documentService).validateBatch(eq(51), any());

        var builder = multipart("/api/documents/quick-upload");
        for (int i = 0; i < 51; i++) {
            builder.file(new org.springframework.mock.web.MockMultipartFile(
                    "files", "f" + i + ".pdf", "application/pdf", new byte[]{1}));
        }

        mockMvc.perform(builder.with(csrf()))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.code").value("BATCH_TOO_LARGE"));
    }

    // ─── PATCH /api/documents/bulk ───────────────────────────────────────────

    private static String bulkBody(String... uuids) {
        StringBuilder sb = new StringBuilder("{\"documentIds\":[");
        for (int i = 0; i < uuids.length; i++) {
            if (i > 0) sb.append(",");
            sb.append("\"").append(uuids[i]).append("\"");
        }
        sb.append("]}");
        return sb.toString();
    }

    @Test
    void patchBulk_returns401_whenUnauthenticated() throws Exception {
        mockMvc.perform(patch("/api/documents/bulk").with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(bulkBody(UUID.randomUUID().toString())))
                .andExpect(status().isUnauthorized());
    }

    @Test
    @WithMockUser
    void patchBulk_returns403_forReadAllUser() throws Exception {
        mockMvc.perform(patch("/api/documents/bulk").with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(bulkBody(UUID.randomUUID().toString())))
                .andExpect(status().isForbidden());
    }

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void patchBulk_returns400_whenDocumentIdsIsEmpty() throws Exception {
        when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());

        mockMvc.perform(patch("/api/documents/bulk").with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"documentIds\":[]}"))
                .andExpect(status().isBadRequest());
    }

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void patchBulk_returns400_whenDocumentIdsIsMissing() throws Exception {
        when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());

        mockMvc.perform(patch("/api/documents/bulk").with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{}"))
                .andExpect(status().isBadRequest());
    }

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void patchBulk_returns400_whenDocumentIdsExceedsCap() throws Exception {
        when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());

        String[] ids = new String[501];
        for (int i = 0; i < 501; i++) ids[i] = UUID.randomUUID().toString();

        mockMvc.perform(patch("/api/documents/bulk").with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(bulkBody(ids)))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.code").value("BULK_EDIT_TOO_MANY_IDS"));
    }

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void patchBulk_returns400_whenArchiveBoxExceeds255Chars() throws Exception {
        // Tobias C2 — DocumentBulkEditDTO.archiveBox carries @Size(max=255).
        // Without @Valid on @RequestBody this would silently land an
        // arbitrarily long string; the test pins both the annotation and
        // the controller-level @Valid wiring.
        when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
        UUID id = UUID.randomUUID();
        String tooLong = "x".repeat(256);

        String body = "{\"documentIds\":[\"" + id + "\"],\"archiveBox\":\"" + tooLong + "\"}";
        mockMvc.perform(patch("/api/documents/bulk").with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(body))
                .andExpect(status().isBadRequest());
    }

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void patchBulk_acceptsExactly500Ids_atTheCap() throws Exception {
        when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
        when(documentService.applyBulkEditToDocument(any(), any(), any()))
                .thenAnswer(inv -> Document.builder().id(inv.getArgument(0)).build());

        String[] ids = new String[500];
        for (int i = 0; i < 500; i++) ids[i] = UUID.randomUUID().toString();

        mockMvc.perform(patch("/api/documents/bulk").with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(bulkBody(ids)))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.updated").value(500));
    }

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void patchBulk_dedupesDuplicateDocumentIds_doesNotInflateUpdatedCount() throws Exception {
        when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
        UUID id = UUID.randomUUID();
        when(documentService.applyBulkEditToDocument(eq(id), any(), any()))
                .thenAnswer(inv -> Document.builder().id(id).build());

        // Same id sent three times — controller should dedupe and call the
        // service exactly once, returning updated=1, not 3.
        mockMvc.perform(patch("/api/documents/bulk").with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(bulkBody(id.toString(), id.toString(), id.toString())))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.updated").value(1));

        verify(documentService, org.mockito.Mockito.times(1))
                .applyBulkEditToDocument(eq(id), any(), any());
    }

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void patchBulk_returns200_andCallsServiceForEachId() throws Exception {
        when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
        UUID id1 = UUID.randomUUID();
        UUID id2 = UUID.randomUUID();
        when(documentService.applyBulkEditToDocument(any(), any(), any()))
                .thenAnswer(inv -> Document.builder().id(inv.getArgument(0)).build());

        mockMvc.perform(patch("/api/documents/bulk").with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(bulkBody(id1.toString(), id2.toString())))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.updated").value(2))
                .andExpect(jsonPath("$.errors").isEmpty());

        verify(documentService).applyBulkEditToDocument(eq(id1), any(), any());
        verify(documentService).applyBulkEditToDocument(eq(id2), any(), any());
    }

    // ─── GET /api/documents/ids ──────────────────────────────────────────────

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

    @Test
    @WithMockUser
    void getDocumentIds_returns403_forUserWithoutWriteAll() throws Exception {
        // /ids is gated WRITE_ALL because it powers the bulk-edit "Alle X
        // editieren" fast path; no other consumer needs it.
        mockMvc.perform(get("/api/documents/ids"))
                .andExpect(status().isForbidden());
    }

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void getDocumentIds_returns200_andDelegatesToService() throws Exception {
        when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
        UUID id = UUID.randomUUID();
        when(documentService.findIdsForFilter(any()))
                .thenReturn(List.of(id));

        mockMvc.perform(get("/api/documents/ids"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$[0]").value(id.toString()));
    }

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void getDocumentIds_passesSenderIdParamToService() throws Exception {
        when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
        UUID senderId = UUID.randomUUID();
        ArgumentCaptor<SearchFilters> filtersCaptor = ArgumentCaptor.forClass(SearchFilters.class);
        when(documentService.findIdsForFilter(any()))
                .thenReturn(List.of());

        mockMvc.perform(get("/api/documents/ids").param("senderId", senderId.toString()))
                .andExpect(status().isOk());

        verify(documentService).findIdsForFilter(filtersCaptor.capture());
        assertThat(filtersCaptor.getValue().sender()).isEqualTo(senderId);
    }

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void getDocumentIds_withoutUndatedParam_coercesNullToFalse() throws Exception {
        // The controller coerces a null boxed Boolean to primitive false
        // (Boolean.TRUE.equals(undated)) so the absent param never NPEs and the
        // record always holds a concrete boolean.
        when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
        ArgumentCaptor<SearchFilters> filtersCaptor = ArgumentCaptor.forClass(SearchFilters.class);
        when(documentService.findIdsForFilter(any()))
                .thenReturn(List.of());

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

        verify(documentService).findIdsForFilter(filtersCaptor.capture());
        assertThat(filtersCaptor.getValue().undated()).isFalse();
    }

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void getDocumentIds_returns400_whenResultExceedsFilterCap() throws Exception {
        when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
        // Service returns 5001 IDs — one over BULK_EDIT_FILTER_MAX_IDS (5000).
        java.util.List<UUID> tooMany = new java.util.ArrayList<>(5001);
        for (int i = 0; i < 5001; i++) tooMany.add(UUID.randomUUID());
        when(documentService.findIdsForFilter(any()))
                .thenReturn(tooMany);

        mockMvc.perform(get("/api/documents/ids"))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.code").value("BULK_EDIT_TOO_MANY_IDS"));
    }

    // ─── POST /api/documents/batch-metadata ──────────────────────────────────

    @Test
    void batchMetadata_returns401_whenUnauthenticated() throws Exception {
        mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"ids\":[\"" + UUID.randomUUID() + "\"]}").with(csrf()))
                .andExpect(status().isUnauthorized());
    }

    @Test
    @WithMockUser
    void batchMetadata_returns403_forUserWithoutReadAll() throws Exception {
        mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"ids\":[\"" + UUID.randomUUID() + "\"]}").with(csrf()))
                .andExpect(status().isForbidden());
    }

    @Test
    @WithMockUser(authorities = "READ_ALL")
    void batchMetadata_returns400_whenIdsEmpty() throws Exception {
        mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"ids\":[]}").with(csrf()))
                .andExpect(status().isBadRequest());
    }

    @Test
    @WithMockUser(authorities = "READ_ALL")
    void batchMetadata_returns400_whenIdsExceedsCap() throws Exception {
        when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
        StringBuilder sb = new StringBuilder("{\"ids\":[");
        for (int i = 0; i < 501; i++) {
            if (i > 0) sb.append(",");
            sb.append("\"").append(UUID.randomUUID()).append("\"");
        }
        sb.append("]}");

        mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(sb.toString()).with(csrf()))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.code").value("BULK_EDIT_TOO_MANY_IDS"));
    }

    @Test
    @WithMockUser(authorities = "READ_ALL")
    void batchMetadata_returnsSummaries_forExistingIds() throws Exception {
        when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
        UUID id = UUID.randomUUID();
        when(documentService.batchMetadata(any())).thenReturn(List.of(
                new org.raddatz.familienarchiv.document.DocumentBatchSummary(id, "Brief", "/api/documents/" + id + "/file")));

        mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"ids\":[\"" + id + "\"]}").with(csrf()))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$[0].id").value(id.toString()))
                .andExpect(jsonPath("$[0].title").value("Brief"))
                .andExpect(jsonPath("$[0].pdfUrl").value("/api/documents/" + id + "/file"));
    }

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void patchBulk_stripsCarriageReturnsAndNewlinesFromErrorMessages() throws Exception {
        // Nora C4 — DocumentController.sanitizeForLog defends against
        // CWE-117 (log injection) by replacing CR/LF in any free-form string
        // it interpolates. Same helper now sanitises BulkEditError.message
        // before it round-trips to the frontend.
        when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
        UUID badId = UUID.randomUUID();
        when(documentService.applyBulkEditToDocument(eq(badId), any(), any()))
                .thenThrow(org.raddatz.familienarchiv.exception.DomainException.notFound(
                        org.raddatz.familienarchiv.exception.ErrorCode.DOCUMENT_NOT_FOUND,
                        "evil\r\nFAKE LOG ENTRY: admin logged in"));

        mockMvc.perform(patch("/api/documents/bulk").with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(bulkBody(badId.toString())))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.errors[0].message",
                        org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString("\n"))))
                .andExpect(jsonPath("$.errors[0].message",
                        org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString("\r"))))
                .andExpect(jsonPath("$.errors[0].message",
                        org.hamcrest.Matchers.containsString("evil_")));
    }

    @Test
    @WithMockUser(authorities = "WRITE_ALL")
    void patchBulk_returnsPartialFailureShape_whenServiceThrowsForOneDocument() throws Exception {
        when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
        UUID okId = UUID.randomUUID();
        UUID badId = UUID.randomUUID();
        when(documentService.applyBulkEditToDocument(eq(okId), any(), any()))
                .thenAnswer(inv -> Document.builder().id(okId).build());
        when(documentService.applyBulkEditToDocument(eq(badId), any(), any()))
                .thenThrow(org.raddatz.familienarchiv.exception.DomainException.notFound(
                        org.raddatz.familienarchiv.exception.ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + badId));

        mockMvc.perform(patch("/api/documents/bulk").with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(bulkBody(okId.toString(), badId.toString())))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.updated").value(1))
                .andExpect(jsonPath("$.errors[0].id").value(badId.toString()))
                .andExpect(jsonPath("$.errors[0].message").value(
                        org.hamcrest.Matchers.containsString("not found")));
    }

    // ─── GET /api/documents/density ───────────────────────────────────────────

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

    @Test
    @WithMockUser
    void density_returns200_withResultBody_whenAuthenticated() throws Exception {
        when(documentService.getDensity(any())).thenReturn(
                new DocumentDensityResult(
                        List.of(new MonthBucket("1915-08", 2), new MonthBucket("1915-09", 1)),
                        java.time.LocalDate.of(1915, 8, 3),
                        java.time.LocalDate.of(1915, 9, 1)));

        mockMvc.perform(get("/api/documents/density"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.buckets").isArray())
                .andExpect(jsonPath("$.buckets[0].month").value("1915-08"))
                .andExpect(jsonPath("$.buckets[0].count").value(2))
                .andExpect(jsonPath("$.minDate").value("1915-08-03"))
                .andExpect(jsonPath("$.maxDate").value("1915-09-01"));
    }

    // Pins produces=APPLICATION_JSON_VALUE on the density mapping so the OpenAPI/TypeScript
    // codegen records application/json instead of the wildcard. Without produces= the
    // request-mapping accepts any Accept header and the OpenAPI emit falls back to the
    // wildcard. Sending an Accept header that JSON cannot satisfy must NOT return 200 —
    // Spring rejects with 406 (HttpMediaTypeNotAcceptableException), which our
    // GlobalExceptionHandler may surface as 400. Either way it proves the route is
    // locked to JSON.
    @Test
    @WithMockUser
    void density_declaresApplicationJsonContentType() throws Exception {
        when(documentService.getDensity(any())).thenReturn(
                new DocumentDensityResult(List.of(), null, null));

        mockMvc.perform(get("/api/documents/density")
                        .accept(MediaType.APPLICATION_XML))
                .andExpect(status().is4xxClientError());
    }

    @Test
    @WithMockUser
    void density_isNeverBrowserCached() throws Exception {
        when(documentService.getDensity(any())).thenReturn(
                new DocumentDensityResult(List.of(), null, null));

        // The endpoint sets no explicit Cache-Control, so Spring Security's
        // default no-store directive applies — the density chart is always fresh.
        mockMvc.perform(get("/api/documents/density"))
                .andExpect(status().isOk())
                .andExpect(header().string("Cache-Control",
                        "no-cache, no-store, max-age=0, must-revalidate"));
    }

    @Test
    @WithMockUser
    void density_forwardsSenderAndTagFilters() throws Exception {
        when(documentService.getDensity(any())).thenReturn(
                new DocumentDensityResult(List.of(), null, null));
        UUID senderId = UUID.randomUUID();

        mockMvc.perform(get("/api/documents/density")
                        .param("senderId", senderId.toString())
                        .param("tag", "Familie")
                        .param("tag", "Urlaub")
                        .param("tagOp", "OR"))
                .andExpect(status().isOk());

        verify(documentService).getDensity(eq(new DensityFilters(
                null, senderId, null,
                List.of("Familie", "Urlaub"),
                null, null,
                org.raddatz.familienarchiv.tag.TagOperator.OR)));
    }

    @Test
    @WithMockUser
    void density_forwardsStatusAndQueryText() throws Exception {
        when(documentService.getDensity(any())).thenReturn(
                new DocumentDensityResult(List.of(), null, null));

        mockMvc.perform(get("/api/documents/density")
                        .param("q", "Brief")
                        .param("status", "REVIEWED"))
                .andExpect(status().isOk());

        verify(documentService).getDensity(eq(new DensityFilters(
                "Brief", null, null, null, null,
                DocumentStatus.REVIEWED,
                org.raddatz.familienarchiv.tag.TagOperator.AND)));
    }

    // ─── CSRF protection ──────────────────────────────────────────────────────

    @Test
    @WithMockUser
    void post_without_csrf_token_returns_403_CSRF_TOKEN_MISSING() throws Exception {
        mockMvc.perform(post("/api/documents")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{}"))
                .andExpect(status().isForbidden())
                .andExpect(jsonPath("$.code").value(ErrorCode.CSRF_TOKEN_MISSING.name()));
    }
}
