package org.raddatz.familienarchiv.importing;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentService;
import org.raddatz.familienarchiv.document.DocumentStatus;
import org.raddatz.familienarchiv.document.ThumbnailAsyncRunner;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.tag.Tag;
import org.raddatz.familienarchiv.tag.TagService;
import org.raddatz.familienarchiv.person.PersonService;
import org.springframework.test.util.ReflectionTestUtils;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;

import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.xml.sax.SAXParseException;

import java.io.File;
import java.io.OutputStream;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;

@ExtendWith(MockitoExtension.class)
class MassImportServiceTest {

    @Mock DocumentService documentService;
    @Mock PersonService personService;
    @Mock TagService tagService;
    @Mock S3Client s3Client;
    @Mock ThumbnailAsyncRunner thumbnailAsyncRunner;

    MassImportService service;

    @BeforeEach
    void setUp() {
        service = new MassImportService(documentService, personService, tagService, s3Client, thumbnailAsyncRunner);
        ReflectionTestUtils.setField(service, "bucketName", "test-bucket");
        ReflectionTestUtils.setField(service, "importDir", "/import");
        ReflectionTestUtils.setField(service, "colIndex", 0);
        ReflectionTestUtils.setField(service, "colBox", 1);
        ReflectionTestUtils.setField(service, "colFolder", 2);
        ReflectionTestUtils.setField(service, "colSender", 3);
        ReflectionTestUtils.setField(service, "colReceivers", 5);
        ReflectionTestUtils.setField(service, "colDate", 7);
        ReflectionTestUtils.setField(service, "colLocation", 9);
        ReflectionTestUtils.setField(service, "colTags", 10);
        ReflectionTestUtils.setField(service, "colSummary", 11);
        ReflectionTestUtils.setField(service, "colTranscription", 13);
    }

    // ─── getStatus ────────────────────────────────────────────────────────────

    @Test
    void getStatus_returnsIdleByDefault() {
        assertThat(service.getStatus().state()).isEqualTo(MassImportService.State.IDLE);
    }

    @Test
    void getStatus_hasStatusCode_IMPORT_IDLE_byDefault() {
        assertThat(service.getStatus().statusCode()).isEqualTo("IMPORT_IDLE");
    }

    // ─── runImportAsync ───────────────────────────────────────────────────────

    @Test
    void runImportAsync_setsFailedStatus_whenImportDirectoryDoesNotExist() {
        // /import directory doesn't exist in test environment → IOException → IMPORT_FAILED_INTERNAL
        service.runImportAsync();

        assertThat(service.getStatus().state()).isEqualTo(MassImportService.State.FAILED);
        assertThat(service.getStatus().statusCode()).isEqualTo("IMPORT_FAILED_INTERNAL");
    }

    @Test
    void runImportAsync_readsFromConfiguredImportDir(@TempDir Path tempDir) {
        // Empty temp dir → findSpreadsheetFile throws "no spreadsheet" with the
        // configured path in the message. Proves the field, not a constant,
        // drives the lookup.
        ReflectionTestUtils.setField(service, "importDir", tempDir.toString());

        service.runImportAsync();

        assertThat(service.getStatus().state()).isEqualTo(MassImportService.State.FAILED);
        assertThat(service.getStatus().message()).contains(tempDir.toString());
    }

    @Test
    void runImportAsync_setsStatusCode_IMPORT_FAILED_NO_SPREADSHEET_whenDirIsEmpty(@TempDir Path tempDir) {
        ReflectionTestUtils.setField(service, "importDir", tempDir.toString());

        service.runImportAsync();

        assertThat(service.getStatus().statusCode()).isEqualTo("IMPORT_FAILED_NO_SPREADSHEET");
    }

    @Test
    void runImportAsync_setsStatusCode_IMPORT_DONE_whenSpreadsheetHasNoDataRows(@TempDir Path tempDir) throws Exception {
        Path xlsx = tempDir.resolve("import.xlsx");
        try (XSSFWorkbook wb = new XSSFWorkbook()) {
            wb.createSheet("Sheet1");
            try (OutputStream out = Files.newOutputStream(xlsx)) {
                wb.write(out);
            }
        }
        ReflectionTestUtils.setField(service, "importDir", tempDir.toString());

        service.runImportAsync();

        assertThat(service.getStatus().statusCode()).isEqualTo("IMPORT_DONE");
    }

    @Test
    void runImportAsync_throwsConflict_whenAlreadyRunning() {
        MassImportService.ImportStatus running = new MassImportService.ImportStatus(
                MassImportService.State.RUNNING, "IMPORT_RUNNING", "Running...", 0, List.of(), LocalDateTime.now());
        ReflectionTestUtils.setField(service, "currentStatus", running);

        assertThatThrownBy(() -> service.runImportAsync())
                .isInstanceOf(DomainException.class)
                .hasMessageContaining("already in progress");
    }

    // ─── importSingleDocument — skip already uploaded ─────────────────────────

    @Test
    void importSingleDocument_skips_whenDocumentAlreadyUploadedNotPlaceholder() {
        Document existing = Document.builder()
                .id(UUID.randomUUID())
                .originalFilename("doc001.pdf")
                .status(DocumentStatus.UPLOADED)
                .build();
        when(documentService.findByOriginalFilename("doc001.pdf")).thenReturn(Optional.of(existing));

        Optional<String> result = service.importSingleDocument(minimalCells("doc001.pdf"), Optional.empty(), "doc001.pdf", "doc001");

        verify(documentService, never()).save(any());
        assertThat(result).isPresent().contains("ALREADY_EXISTS");
    }

    // ─── importSingleDocument — already-exists guard fires before file I/O ─────

    @Test
    void importSingleDocument_skipsWithAlreadyExists_whenDocumentUploadedAndFileIsPresent(@TempDir Path tempDir) throws Exception {
        // Document already exists with status UPLOADED (not PLACEHOLDER).
        // A physical PDF file is also present on disk (valid magic bytes).
        // Expected: ALREADY_EXISTS is returned and no S3 upload is attempted —
        // the guard fires before any file I/O, so no partial processing occurs.
        Document existing = Document.builder()
                .id(UUID.randomUUID())
                .originalFilename("present.pdf")
                .status(DocumentStatus.UPLOADED)
                .build();
        when(documentService.findByOriginalFilename("present.pdf")).thenReturn(Optional.of(existing));

        Path physicalFile = tempDir.resolve("present.pdf");
        byte[] pdfHeader = {0x25, 0x50, 0x44, 0x46, 0x2D}; // %PDF-
        Files.write(physicalFile, pdfHeader);

        Optional<String> result = service.importSingleDocument(
                minimalCells("present.pdf"), Optional.of(physicalFile.toFile()), "present.pdf", "present");

        assertThat(result).isPresent().contains("ALREADY_EXISTS");
        verify(s3Client, never()).putObject(any(PutObjectRequest.class), any(RequestBody.class));
        verify(documentService, never()).save(any());
    }

    // ─── importSingleDocument — S3 failure surfaced in skippedFiles ──────────

    @Test
    void runImportAsync_addsS3UploadFailed_toSkippedFiles_whenS3Throws(@TempDir Path tempDir) throws Exception {
        byte[] pdfHeader = {0x25, 0x50, 0x44, 0x46, 0x2D}; // %PDF-
        Files.write(tempDir.resolve("upload_fail.pdf"), pdfHeader);
        buildMinimalImportXlsx(tempDir, "upload_fail.pdf");
        ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
        when(documentService.findByOriginalFilename("upload_fail.pdf")).thenReturn(Optional.empty());
        doThrow(new RuntimeException("S3 unavailable"))
                .when(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class));

        service.runImportAsync();

        assertThat(service.getStatus().skipped()).isEqualTo(1);
        assertThat(service.getStatus().skippedFiles())
                .extracting(MassImportService.SkippedFile::filename, MassImportService.SkippedFile::reason)
                .containsExactly(org.assertj.core.groups.Tuple.tuple("upload_fail.pdf", "S3_UPLOAD_FAILED"));
    }

    @Test
    void runImportAsync_addsAlreadyExists_toSkippedFiles_whenDocumentAlreadyUploaded(@TempDir Path tempDir) throws Exception {
        buildMinimalImportXlsx(tempDir, "existing.pdf");
        ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
        Document existing = Document.builder()
                .id(UUID.randomUUID())
                .originalFilename("existing.pdf")
                .status(DocumentStatus.UPLOADED)
                .build();
        when(documentService.findByOriginalFilename("existing.pdf")).thenReturn(Optional.of(existing));

        service.runImportAsync();

        assertThat(service.getStatus().skipped()).isEqualTo(1);
        assertThat(service.getStatus().skippedFiles())
                .extracting(MassImportService.SkippedFile::reason)
                .containsExactly("ALREADY_EXISTS");
    }

    // ─── importSingleDocument — create new document (metadata only) ───────────

    @Test
    void importSingleDocument_createsNewDocument_whenNotExists() {
        when(documentService.findByOriginalFilename("doc002.pdf")).thenReturn(Optional.empty());
        when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));

        service.importSingleDocument(minimalCells("doc002.pdf"), Optional.empty(), "doc002.pdf", "doc002");

        verify(documentService).save(argThat(d ->
                d.getOriginalFilename().equals("doc002.pdf")
                        && d.getStatus() == DocumentStatus.PLACEHOLDER));
    }

    // ─── importSingleDocument — update existing placeholder ──────────────────

    @Test
    void importSingleDocument_updatesExistingPlaceholder() {
        Document placeholder = Document.builder()
                .id(UUID.randomUUID())
                .originalFilename("existing.pdf")
                .status(DocumentStatus.PLACEHOLDER)
                .build();
        when(documentService.findByOriginalFilename("existing.pdf")).thenReturn(Optional.of(placeholder));
        when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));

        service.importSingleDocument(minimalCells("existing.pdf"), Optional.empty(), "existing.pdf", "existing");

        verify(documentService).save(same(placeholder));
    }

    // ─── importSingleDocument — with file (S3 upload) ─────────────────────────

    @Test
    void importSingleDocument_uploadsFileToS3_andSetsStatusUploaded(@TempDir Path tempDir) throws Exception {
        Path tempFile = tempDir.resolve("doc003.pdf");
        Files.write(tempFile, "PDF content".getBytes());

        when(documentService.findByOriginalFilename("doc003.pdf")).thenReturn(Optional.empty());
        when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));

        service.importSingleDocument(
                minimalCells("doc003.pdf"), Optional.of(tempFile.toFile()), "doc003.pdf", "doc003");

        verify(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class));
        verify(documentService).save(argThat(d -> d.getStatus() == DocumentStatus.UPLOADED));
    }

    @Test
    void importSingleDocument_returnsS3UploadFailed_whenS3UploadFails(@TempDir Path tempDir) throws Exception {
        Path tempFile = tempDir.resolve("fail.pdf");
        Files.write(tempFile, "data".getBytes());

        when(documentService.findByOriginalFilename("fail.pdf")).thenReturn(Optional.empty());
        doThrow(new RuntimeException("S3 error"))
                .when(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class));

        Optional<String> result = service.importSingleDocument(
                minimalCells("fail.pdf"), Optional.of(tempFile.toFile()), "fail.pdf", "fail");

        verify(documentService, never()).save(any());
        assertThat(result).isPresent().contains("S3_UPLOAD_FAILED");
    }

    // ─── importSingleDocument — sender handling ───────────────────────────────

    @Test
    void importSingleDocument_setsNullSender_whenSenderCellIsBlank() {
        when(documentService.findByOriginalFilename("nosender.pdf")).thenReturn(Optional.empty());
        when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));

        List<String> cells = buildCells("nosender.pdf", "", "", "");
        service.importSingleDocument(cells, Optional.empty(), "nosender.pdf", "nosender");

        verify(documentService).save(argThat(d -> d.getSender() == null));
        verify(personService, never()).findOrCreateByAlias(any());
    }

    @Test
    void importSingleDocument_createsSender_whenSenderCellIsNonBlank() {
        Person sender = Person.builder().id(UUID.randomUUID()).firstName("Walter").lastName("Müller").build();
        when(documentService.findByOriginalFilename("withsender.pdf")).thenReturn(Optional.empty());
        when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
        when(personService.findOrCreateByAlias("Walter Müller")).thenReturn(sender);

        List<String> cells = buildCells("withsender.pdf", "Walter Müller", "", "");
        service.importSingleDocument(cells, Optional.empty(), "withsender.pdf", "withsender");

        verify(personService).findOrCreateByAlias("Walter Müller");
        verify(documentService).save(argThat(d -> d.getSender() == sender));
    }

    // ─── importSingleDocument — tag handling ─────────────────────────────────

    @Test
    void importSingleDocument_createsTag_whenTagCellIsNonBlank() {
        Tag tag = Tag.builder().id(UUID.randomUUID()).name("Familie").build();
        when(documentService.findByOriginalFilename("tagged.pdf")).thenReturn(Optional.empty());
        when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
        when(tagService.findOrCreate("Familie")).thenReturn(tag);

        List<String> cells = buildCells("tagged.pdf", "", "", "Familie");
        service.importSingleDocument(cells, Optional.empty(), "tagged.pdf", "tagged");

        verify(tagService).findOrCreate("Familie");
    }

    @Test
    void importSingleDocument_doesNotCreateTag_whenTagCellIsBlank() {
        when(documentService.findByOriginalFilename("notag.pdf")).thenReturn(Optional.empty());
        when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));

        List<String> cells = buildCells("notag.pdf", "", "", "");
        service.importSingleDocument(cells, Optional.empty(), "notag.pdf", "notag");

        verify(tagService, never()).findOrCreate(any());
    }

    // ─── importSingleDocument — metadataComplete heuristic ───────────────────

    @Test
    void importSingleDocument_metadataComplete_whenSenderPresent() {
        Person sender = Person.builder().id(UUID.randomUUID()).firstName("A").lastName("B").build();
        when(documentService.findByOriginalFilename("meta.pdf")).thenReturn(Optional.empty());
        when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
        when(personService.findOrCreateByAlias("A B")).thenReturn(sender);

        List<String> cells = buildCells("meta.pdf", "A B", "", "");
        service.importSingleDocument(cells, Optional.empty(), "meta.pdf", "meta");

        verify(documentService).save(argThat(Document::isMetadataComplete));
    }

    @Test
    void importSingleDocument_metadataIncomplete_whenNoKeyFieldsPresent() {
        when(documentService.findByOriginalFilename("nometa.pdf")).thenReturn(Optional.empty());
        when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));

        List<String> cells = buildCells("nometa.pdf", "", "", "");
        service.importSingleDocument(cells, Optional.empty(), "nometa.pdf", "nometa");

        verify(documentService).save(argThat(d -> !d.isMetadataComplete()));
    }

    // ─── importSingleDocument — blank fields set to null ─────────────────────

    @Test
    void importSingleDocument_setsBlankFieldsToNull() {
        when(documentService.findByOriginalFilename("blank.pdf")).thenReturn(Optional.empty());
        when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));

        List<String> cells = buildCells("blank.pdf", "", "", "");
        service.importSingleDocument(cells, Optional.empty(), "blank.pdf", "blank");

        verify(documentService).save(argThat(d ->
                d.getLocation() == null &&
                d.getSummary() == null &&
                d.getTranscription() == null &&
                d.getArchiveBox() == null &&
                d.getArchiveFolder() == null));
    }

    // ─── processRows — via ReflectionTestUtils ────────────────────────────────

    @Test
    void processRows_returnsZero_whenOnlyHeaderRow() {
        List<List<String>> rows = List.of(List.of("header", "col1"));
        MassImportService.ProcessResult result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
        assertThat(result.processed()).isEqualTo(0);
    }

    @Test
    void processRows_skipsRowWithBlankIndex() {
        List<List<String>> rows = List.of(
                List.of("header"),
                minimalCells("")  // blank index
        );
        MassImportService.ProcessResult result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
        assertThat(result.processed()).isEqualTo(0);
        verify(documentService, never()).findByOriginalFilename(any());
    }

    @Test
    void processRows_addsExtension_whenIndexHasNoDot() {
        when(documentService.findByOriginalFilename("doc001.pdf")).thenReturn(Optional.empty());
        when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));

        List<List<String>> rows = List.of(
                List.of("header"),
                minimalCells("doc001")  // no dot → appends ".pdf"
        );
        MassImportService.ProcessResult result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);

        assertThat(result.processed()).isEqualTo(1);
        verify(documentService).findByOriginalFilename("doc001.pdf");
    }

    @Test
    void processRows_usesFilenameAsIs_whenIndexHasDot() {
        when(documentService.findByOriginalFilename("doc002.pdf")).thenReturn(Optional.empty());
        when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));

        List<List<String>> rows = List.of(
                List.of("header"),
                minimalCells("doc002.pdf")  // has dot → used as-is
        );
        MassImportService.ProcessResult result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);

        assertThat(result.processed()).isEqualTo(1);
        verify(documentService).findByOriginalFilename("doc002.pdf");
    }

    // ─── importSingleDocument — non-blank optional fields ────────────────────

    @Test
    void importSingleDocument_setsNonNullOptionalFields_whenPresent() {
        when(documentService.findByOriginalFilename("rich.pdf")).thenReturn(Optional.empty());
        when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));

        // box=1, folder=2, location=9, summary=11, transcription=13
        List<String> cells = List.of(
                "rich.pdf",      // 0: index
                "Box A",         // 1: box
                "Folder B",      // 2: folder
                "",              // 3: sender
                "",              // 4: unused
                "",              // 5: receivers
                "",              // 6: unused
                "",              // 7: date
                "",              // 8: unused
                "Hamburg",       // 9: location
                "",              // 10: tags
                "A summary",     // 11: summary
                "",              // 12: unused
                "A transcript"   // 13: transcription
        );

        service.importSingleDocument(cells, Optional.empty(), "rich.pdf", "rich");

        verify(documentService).save(argThat(d ->
                "Box A".equals(d.getArchiveBox()) &&
                "Folder B".equals(d.getArchiveFolder()) &&
                "Hamburg".equals(d.getLocation()) &&
                "A summary".equals(d.getSummary()) &&
                "A transcript".equals(d.getTranscription())));
    }

    @Test
    void importSingleDocument_setsMetadataComplete_whenReceiversArePresent() {
        Person receiver = Person.builder().id(UUID.randomUUID()).firstName("Walter").lastName("Müller").build();
        when(documentService.findByOriginalFilename("rcv.pdf")).thenReturn(Optional.empty());
        when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
        when(personService.findOrCreateByAlias("Walter Müller")).thenReturn(receiver);

        List<String> cells = List.of(
                "rcv.pdf", "", "", "", "", "Walter Müller", "", "", "", "", "", "", "", "");
        service.importSingleDocument(cells, Optional.empty(), "rcv.pdf", "rcv");

        verify(documentService).save(argThat(Document::isMetadataComplete));
    }

    @Test
    void importSingleDocument_setsMetadataComplete_whenDateIsPresent() {
        when(documentService.findByOriginalFilename("dated.pdf")).thenReturn(Optional.empty());
        when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));

        List<String> cells = List.of(
                "dated.pdf", "", "", "", "", "", "", "2024-03-15", "", "", "", "", "", "");
        service.importSingleDocument(cells, Optional.empty(), "dated.pdf", "dated");

        verify(documentService).save(argThat(Document::isMetadataComplete));
    }

    // ─── buildTitle — null location ───────────────────────────────────────────

    @Test
    void buildTitle_withNullLocation_skipsLocationPart() {
        String result = ReflectionTestUtils.invokeMethod(service, "buildTitle",
                "doc005", LocalDate.of(1940, 5, 1), (String) null);
        assertThat(result).contains("doc005").contains("1940");
        assertThat(result).doesNotContain("Berlin");
    }

    // ─── parseDate — via ReflectionTestUtils ─────────────────────────────────

    @Test
    void parseDate_returnsNull_whenValueIsNull() {
        LocalDate result = ReflectionTestUtils.invokeMethod(service, "parseDate", (String) null);
        assertThat(result).isNull();
    }

    @Test
    void parseDate_returnsNull_whenValueIsBlank() {
        LocalDate result = ReflectionTestUtils.invokeMethod(service, "parseDate", "   ");
        assertThat(result).isNull();
    }

    @Test
    void parseDate_returnsDate_whenValidIsoFormat() {
        LocalDate result = ReflectionTestUtils.invokeMethod(service, "parseDate", "2024-03-15");
        assertThat(result).isEqualTo(LocalDate.of(2024, 3, 15));
    }

    @Test
    void parseDate_returnsNull_whenInvalidDateString() {
        LocalDate result = ReflectionTestUtils.invokeMethod(service, "parseDate", "15.03.2024");
        assertThat(result).isNull();
    }

    // ─── buildTitle — via ReflectionTestUtils ────────────────────────────────

    @Test
    void buildTitle_withDateAndLocation() {
        String result = ReflectionTestUtils.invokeMethod(service, "buildTitle",
                "doc001", LocalDate.of(1940, 5, 1), "Berlin");
        assertThat(result).contains("doc001").contains("Berlin").contains("1940");
    }

    @Test
    void buildTitle_withDateOnly() {
        String result = ReflectionTestUtils.invokeMethod(service, "buildTitle",
                "doc002", LocalDate.of(1960, 8, 15), "");
        assertThat(result).contains("doc002").contains("1960");
        assertThat(result).doesNotContain("Berlin");
    }

    @Test
    void buildTitle_withIndexOnly_whenDateAndLocationAreNull() {
        String result = ReflectionTestUtils.invokeMethod(service, "buildTitle",
                "doc003", null, "");
        assertThat(result).isEqualTo("doc003");
    }

    @Test
    void buildTitle_withLocationOnly_whenDateIsNull() {
        // date=null, location present → date part skipped, location appended
        String result = ReflectionTestUtils.invokeMethod(service, "buildTitle",
                "doc004", null, "Berlin");
        assertThat(result).contains("doc004").contains("Berlin");
        assertThat(result).doesNotContain("("); // no date part
    }

    // ─── getCell — via ReflectionTestUtils ───────────────────────────────────

    @Test
    void getCell_returnsEmptyString_whenColBeyondListSize() {
        List<String> cells = List.of("a", "b");
        String result = ReflectionTestUtils.invokeMethod(service, "getCell", cells, 5);
        assertThat(result).isEmpty();
    }

    @Test
    void getCell_returnsEmptyString_whenValueIsNull() {
        List<String> cells = new ArrayList<>();
        cells.add(null);
        cells.add("b");
        String result = ReflectionTestUtils.invokeMethod(service, "getCell", cells, 0);
        assertThat(result).isEmpty();
    }

    @Test
    void getCell_returnsTrimmedValue() {
        List<String> cells = List.of("  hello  ", "world");
        String result = ReflectionTestUtils.invokeMethod(service, "getCell", cells, 0);
        assertThat(result).isEqualTo("hello");
    }

    // ─── PDF magic byte validation regression ─────────────────────────────────

    @Test
    void runImportAsync_uploadsValidPdf_andSkipsFakeOne(@TempDir Path tempDir) throws Exception {
        setupOneValidOneFakeImport(tempDir);

        service.runImportAsync();

        verify(s3Client, times(1)).putObject(any(PutObjectRequest.class), any(RequestBody.class));
    }

    @Test
    void runImportAsync_setsSkippedCount_toOne_whenOneFakeFile(@TempDir Path tempDir) throws Exception {
        setupOneValidOneFakeImport(tempDir);

        service.runImportAsync();

        assertThat(service.getStatus().skipped()).isEqualTo(1);
    }

    @Test
    void runImportAsync_includesRejectedFilename_inSkippedFiles(@TempDir Path tempDir) throws Exception {
        setupOneValidOneFakeImport(tempDir);

        service.runImportAsync();

        assertThat(service.getStatus().skippedFiles())
                .extracting(MassImportService.SkippedFile::filename)
                .contains("fake.pdf");
    }

    @Test
    void runImportAsync_skipsFile_whenShorterThanFourBytes(@TempDir Path tempDir) throws Exception {
        Files.write(tempDir.resolve("tiny.pdf"), new byte[]{0x25, 0x50, 0x44}); // only 3 bytes
        buildMinimalImportXlsx(tempDir, "tiny.pdf");
        ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
        lenient().when(documentService.findByOriginalFilename(any())).thenReturn(Optional.empty());

        service.runImportAsync();

        assertThat(service.getStatus().skipped()).isEqualTo(1);
    }

    @Test
    void runImportAsync_skipsFile_whenMagicBytesCheckThrowsIOException(@TempDir Path tempDir) throws Exception {
        Files.writeString(tempDir.resolve("unreadable.pdf"), "some content");
        buildMinimalImportXlsx(tempDir, "unreadable.pdf");
        ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
        lenient().when(documentService.findByOriginalFilename(any())).thenReturn(Optional.empty());

        MassImportService spyService = spy(service);
        doThrow(new java.io.IOException("simulated read error")).when(spyService).openFileStream(any(File.class));

        spyService.runImportAsync();

        assertThat(spyService.getStatus().skipped()).isEqualTo(1);
        assertThat(spyService.getStatus().skippedFiles())
                .extracting(MassImportService.SkippedFile::reason)
                .containsExactly("FILE_READ_ERROR");
    }

    // ─── readOds — XXE security regression ───────────────────────────────────

    // Security regression — do not remove.
    @Test
    void readOds_rejects_xxe_doctype_payload(@TempDir Path tempDir) throws Exception {
        File malicious = buildXxeOds(tempDir, "file:///etc/hostname");
        assertThatThrownBy(() -> service.readOds(malicious))
                .isInstanceOf(SAXParseException.class)
                .hasMessageContaining("DOCTYPE is disallowed");
    }

    @Test
    void readOds_parses_valid_ods_correctly(@TempDir Path tempDir) throws Exception {
        File valid = buildValidOds(tempDir, "Mustermann");
        List<List<String>> rows = service.readOds(valid);
        assertThat(rows).isNotEmpty();
        assertThat(rows.get(0)).contains("Mustermann");
    }

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

    /**
     * Builds a minimal 14-element cell row with the given filename at index 0
     * and blanks for all optional fields.
     */
    private List<String> minimalCells(String filename) {
        return buildCells(filename, "", "", "");
    }

    /**
     * Builds a cell row with sender, receiver, and tag controls.
     * Layout matches the default column indices set in setUp().
     */
    private List<String> buildCells(String filename, String sender, String receivers, String tag) {
        // 14 elements: index=0,box=1,folder=2,sender=3,[4],receivers=5,[6],date=7,[8],location=9,tag=10,summary=11,[12],transcription=13
        return List.of(
                filename,  // 0: index
                "",        // 1: box
                "",        // 2: folder
                sender,    // 3: sender
                "",        // 4: (unused)
                receivers, // 5: receivers
                "",        // 6: (unused)
                "",        // 7: date
                "",        // 8: (unused)
                "",        // 9: location
                tag,       // 10: tags
                "",        // 11: summary
                "",        // 12: (unused)
                ""         // 13: transcription
        );
    }

    /** Creates a minimal ODS ZIP containing a content.xml with an XXE payload. */
    private File buildXxeOds(Path dir, String entityTarget) throws Exception {
        String xml = "<?xml version=\"1.0\"?>"
                + "<!DOCTYPE foo [<!ENTITY xxe SYSTEM \"" + entityTarget + "\">]>"
                + "<office:document-content"
                + " xmlns:office=\"urn:oasis:names:tc:opendocument:xmlns:office:1.0\""
                + " xmlns:table=\"urn:oasis:names:tc:opendocument:xmlns:table:1.0\""
                + " xmlns:text=\"urn:oasis:names:tc:opendocument:xmlns:text:1.0\">"
                + "<office:body><office:spreadsheet>"
                + "<table:table><table:table-row><table:table-cell>"
                + "<text:p>&xxe;</text:p>"
                + "</table:table-cell></table:table-row></table:table>"
                + "</office:spreadsheet></office:body>"
                + "</office:document-content>";
        return writeOdsZip(dir.resolve("malicious.ods"), xml);
    }

    /** Creates a minimal valid ODS ZIP containing a content.xml with the given cell value.
     *  cellValue must not contain XML metacharacters ({@code < > &}). */
    private File buildValidOds(Path dir, String cellValue) throws Exception {
        String xml = "<?xml version=\"1.0\"?>"
                + "<office:document-content"
                + " xmlns:office=\"urn:oasis:names:tc:opendocument:xmlns:office:1.0\""
                + " xmlns:table=\"urn:oasis:names:tc:opendocument:xmlns:table:1.0\""
                + " xmlns:text=\"urn:oasis:names:tc:opendocument:xmlns:text:1.0\">"
                + "<office:body><office:spreadsheet>"
                + "<table:table><table:table-row><table:table-cell>"
                + "<text:p>" + cellValue + "</text:p>"
                + "</table:table-cell></table:table-row></table:table>"
                + "</office:spreadsheet></office:body>"
                + "</office:document-content>";
        return writeOdsZip(dir.resolve("valid.ods"), xml);
    }

    private File writeOdsZip(Path destination, String contentXml) throws Exception {
        try (OutputStream fos = Files.newOutputStream(destination);
             ZipOutputStream zip = new ZipOutputStream(fos)) {
            zip.putNextEntry(new ZipEntry("content.xml"));
            zip.write(contentXml.getBytes(StandardCharsets.UTF_8));
            zip.closeEntry();
        }
        return destination.toFile();
    }

    private void setupOneValidOneFakeImport(Path tempDir) throws Exception {
        byte[] pdfHeader = {0x25, 0x50, 0x44, 0x46, 0x2D}; // %PDF-
        Files.write(tempDir.resolve("real.pdf"), pdfHeader);
        Files.writeString(tempDir.resolve("fake.pdf"), "not a pdf");
        buildMinimalImportXlsx(tempDir, "real.pdf", "fake.pdf");
        ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
        when(documentService.findByOriginalFilename(any())).thenReturn(Optional.empty());
        when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
    }

    private void buildMinimalImportXlsx(Path dir, String... filenames) throws Exception {
        Path xlsx = dir.resolve("import.xlsx");
        try (XSSFWorkbook wb = new XSSFWorkbook()) {
            org.apache.poi.ss.usermodel.Sheet sheet = wb.createSheet("Sheet1");
            sheet.createRow(0).createCell(0).setCellValue("Index");
            for (int i = 0; i < filenames.length; i++) {
                sheet.createRow(i + 1).createCell(0).setCellValue(filenames[i]);
            }
            try (OutputStream out = Files.newOutputStream(xlsx)) {
                wb.write(out);
            }
        }
    }
}
