package org.raddatz.familienarchiv.importing;

import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
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.document.Document;
import org.raddatz.familienarchiv.document.DocumentService;
import org.raddatz.familienarchiv.document.DocumentTitleFactory;
import org.raddatz.familienarchiv.document.DocumentStatus;
import org.raddatz.familienarchiv.document.ThumbnailAsyncRunner;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonService;
import org.raddatz.familienarchiv.person.PersonUpsertCommand;
import org.raddatz.familienarchiv.tag.Tag;
import org.raddatz.familienarchiv.tag.TagService;
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 java.io.File;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
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.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
class DocumentImporterTest {

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

    DocumentImporter importer;

    @BeforeEach
    void setUp() throws java.io.IOException {
        // Default opener delegates to FileInputStream — tests that need to force an IOException
        // override this stub locally (load_skipsFile_whenMagicByteCheckThrowsIoException).
        lenient().when(fileStreamOpener.open(any(File.class)))
                .thenAnswer(inv -> new java.io.FileInputStream(inv.getArgument(0, File.class)));
        // Real factory (pure, dependency-free) so the title-content assertions below exercise
        // the shared composition rather than a stub — the #726 single source of truth.
        importer = new DocumentImporter(documentService, new DocumentTitleFactory(), personService,
                tagService, s3Client, thumbnailAsyncRunner, fileStreamOpener);
        ReflectionTestUtils.setField(importer, "bucketName", "test-bucket");
    }

    // ─── index validation — a malicious/garbage index can never reach disk I/O ─────────

    @Test
    void isValidImportIndex_returnsFalse_whenNull() {
        assertThat(validIndex(null)).isFalse();
    }

    @Test
    void isValidImportIndex_returnsFalse_whenBlank() {
        assertThat(validIndex("   ")).isFalse();
    }

    @Test
    void isValidImportIndex_returnsFalse_whenForwardSlash() {
        assertThat(validIndex("etc/passwd")).isFalse();
    }

    @Test
    void isValidImportIndex_returnsFalse_whenBackslash() {
        assertThat(validIndex("..\\etc\\passwd")).isFalse();
    }

    @Test
    void isValidImportIndex_returnsFalse_whenDotDot() {
        assertThat(validIndex("W-..0001")).isFalse();
    }

    @Test
    void isValidImportIndex_returnsFalse_whenIsDotDot() {
        assertThat(validIndex("..")).isFalse();
    }

    @Test
    void isValidImportIndex_returnsFalse_whenSingleDot() {
        assertThat(validIndex(".")).isFalse();
    }

    @Test
    void isValidImportIndex_returnsFalse_whenAbsolutePath() {
        assertThat(validIndex("/etc/passwd")).isFalse();
    }

    @Test
    void isValidImportIndex_returnsFalse_whenNullByte() {
        assertThat(validIndex("W-0001\0")).isFalse();
    }

    @Test
    void isValidImportIndex_returnsFalse_whenUnicodeDivisionSlash() {
        assertThat(validIndex("W∕0001")).isFalse();
    }

    @Test
    void isValidImportIndex_returnsFalse_whenFullwidthSlash() {
        assertThat(validIndex("W／0001")).isFalse();
    }

    @Test
    void isValidImportIndex_returnsFalse_whenReverseSolidusOperator() {
        assertThat(validIndex("W⧵0001")).isFalse();
    }

    @Test
    void isValidImportIndex_returnsFalse_whenContainsDotPdfExtension() {
        // The index is the bare catalog id; appending ".pdf" is the importer's job. A dot in
        // the index would let "W-0001.pdf" become "W-0001.pdf.pdf" or smuggle an extension.
        assertThat(validIndex("W-0001.pdf")).isFalse();
    }

    // ─── catalog-shape rejects — pass the char pre-checks but must fail INDEX_PATTERN ────
    // These pin the regex branch itself: each string contains no separator, dot, slash
    // homoglyph, null byte, or absolute marker, so it sails past every char guard and is
    // rejected *only* because INDEX_PATTERN.matches() returns false. A weaker pattern would
    // let them through — these tests would then go red.

    @Test
    void isValidImportIndex_returnsFalse_whenSpaceInIndex() {
        // The real-world reject: "J 0070" is a space-typo with no PDF on disk.
        assertThat(validIndex("J 0070")).isFalse();
    }

    @Test
    void isValidImportIndex_returnsFalse_whenFiveLetterPrefix() {
        // The catalog prefix is at most 4 letters; 5 must not match.
        assertThat(validIndex("WXYZA-0001")).isFalse();
    }

    @Test
    void isValidImportIndex_returnsFalse_whenNoLetterPrefix() {
        // A digit-led id (no letter prefix) is not a catalog shape.
        assertThat(validIndex("12-0001")).isFalse();
    }

    @Test
    void isValidImportIndex_returnsFalse_whenUppercaseXSuffix() {
        // Only a lowercase trailing "x" is allowed; an uppercase "X" suffix must fail.
        assertThat(validIndex("W-0001X")).isFalse();
    }

    @Test
    void isValidImportIndex_returnsTrue_whenPlainCatalogIndex() {
        assertThat(validIndex("W-0124")).isTrue();
    }

    @Test
    void isValidImportIndex_returnsTrue_whenTwoLetterPrefix() {
        assertThat(validIndex("Al-0001")).isTrue();
    }

    @Test
    void isValidImportIndex_returnsTrue_whenThreeLetterPrefix() {
        assertThat(validIndex("CuH-0010")).isTrue();
    }

    @Test
    void isValidImportIndex_returnsTrue_whenUmlautPrefix() {
        // Real corpus indices carry a German umlaut, e.g. "Mü-0001.pdf" exists on disk.
        assertThat(validIndex("Mü-0001")).isTrue();
    }

    @Test
    void isValidImportIndex_returnsTrue_whenDoubleHyphen() {
        // Real corpus: "C--0029" appears in the spreadsheet (a data-entry artefact, but a
        // legitimate catalog shape that must still resolve, not crash).
        assertThat(validIndex("C--0029")).isTrue();
    }

    @Test
    void isValidImportIndex_returnsTrue_whenXSuffix() {
        // The normalizer recognises an x-suffix catalog id; allow it defensively.
        assertThat(validIndex("W-0001x")).isTrue();
    }

    // ─── a valid index resolves to exactly importDir/<index>.pdf within containment ─────

    @Test
    void load_resolvesPdfByIndex_uploadsToS3_andSetsStatusUploaded(@TempDir Path tempDir) throws Exception {
        ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
        byte[] pdf = {0x25, 0x50, 0x44, 0x46, 0x2D};
        Files.write(tempDir.resolve("W-0124.pdf"), pdf);
        when(documentService.findByOriginalFilename("W-0124")).thenReturn(Optional.empty());
        when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
        Path xlsx = writeDocs(tempDir, docRow("W-0124", "", "", "", "", "", "", "", ""));

        importer.load(xlsx.toFile());

        // exactly importDir/<index>.pdf was uploaded — the S3 key carries that basename
        org.mockito.ArgumentCaptor<RequestBody> bodyCaptor = org.mockito.ArgumentCaptor.forClass(RequestBody.class);
        verify(s3Client).putObject(any(PutObjectRequest.class), bodyCaptor.capture());
        verify(documentService).save(org.mockito.ArgumentMatchers.argThat(d ->
                d.getStatus() == DocumentStatus.UPLOADED
                        && d.getFilePath() != null
                        && d.getFilePath().endsWith("_W-0124.pdf")));
    }

    @Test
    void load_yieldsPlaceholder_whenIndexedPdfMissing(@TempDir Path tempDir) throws Exception {
        ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
        when(documentService.findByOriginalFilename("X-9999")).thenReturn(Optional.empty());
        when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
        Path xlsx = writeDocs(tempDir, docRow("X-9999", "", "", "", "", "", "", "", ""));

        importer.load(xlsx.toFile());

        verify(documentService).save(org.mockito.ArgumentMatchers.argThat(d -> d.getStatus() == DocumentStatus.PLACEHOLDER));
        verify(s3Client, never()).putObject(any(PutObjectRequest.class), any(RequestBody.class));
    }

    @Test
    void load_rejectsMaliciousIndex_neverReadsOutsideImportDir(@TempDir Path tempDir) throws Exception {
        // An index with a path separator must be skipped outright, never used for disk I/O.
        ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
        Path xlsx = writeDocs(tempDir, docRow("../../etc/cron.d/x", "", "", "", "", "", "", "", ""));

        DocumentImporter.LoadResult result = importer.load(xlsx.toFile());

        assertThat(result.skippedFiles())
                .extracting(ImportStatus.SkippedFile::reason)
                .containsExactly(ImportStatus.SkipReason.INVALID_FILENAME_PATH_TRAVERSAL);
        verify(documentService, never()).save(any());
        verify(s3Client, never()).putObject(any(PutObjectRequest.class), any(RequestBody.class));
    }

    @Test
    void resolvePdfByIndex_throwsWhenResolvedPathEscapesImportDir_viaSymlink(
            @TempDir Path importDirPath, @TempDir Path outsideDir) throws Exception {
        // Containment defense-in-depth: even a syntactically valid index whose <index>.pdf is a
        // symlink pointing outside importDir must be refused — the resolved canonical path is
        // asserted to stay inside importDir.
        Path outsideFile = outsideDir.resolve("secret.pdf");
        Files.writeString(outsideFile, "sensitive");
        Files.createSymbolicLink(importDirPath.resolve("W-0001.pdf"), outsideFile);
        ReflectionTestUtils.setField(importer, "importDir", importDirPath.toString());

        org.assertj.core.api.Assertions.assertThatThrownBy(
                        () -> ReflectionTestUtils.invokeMethod(importer, "resolvePdfByIndex", "W-0001", 2))
                .isInstanceOf(org.raddatz.familienarchiv.exception.DomainException.class);
    }

    @Test
    void resolvePdfByIndex_returnsExactlyImportDirIndexPdf_whenPresent(@TempDir Path tempDir) throws Exception {
        ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
        Path expected = tempDir.resolve("Eu-0628.pdf");
        Files.writeString(expected, "%PDF-1.4");

        Optional<File> resolved = ReflectionTestUtils.invokeMethod(importer, "resolvePdfByIndex", "Eu-0628", 2);

        assertThat(resolved).isPresent();
        assertThat(resolved.get().getCanonicalFile()).isEqualTo(expected.toFile().getCanonicalFile());
    }

    // NOTE (Sara, PR #687): the IOException branch of resolvePdfByIndex — where
    // File.getCanonicalPath() itself throws (an OS-level failure mid-resolution, not the
    // symlink-escape DomainException) — is intentionally NOT covered by a test. Unlike
    // isPdfMagicBytes, which has the package-private openFileStream(File) seam a Mockito spy can
    // make throw, getCanonicalPath() is called on a File built internally with no injection seam,
    // and there is no portable, deterministic way to make it throw on a temp file (it does not
    // throw for missing/symlinked paths — those are handled by isFile()/the containment check).
    // Adding a seam purely to test this would be production code in service of a non-defect; the
    // substantive fix is the log.warn() now emitted in that branch so the quiet skip surfaces in
    // ops. Left uncovered by deliberate decision, documented here so the branch is not assumed
    // tested.

    // ─── PDF magic-byte guard — ported — do not remove ──────────────────────────────

    @Test
    void load_skipsFile_whenNotPdfMagicBytes(@TempDir Path tempDir) throws Exception {
        ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
        Files.writeString(tempDir.resolve("W-0001.pdf"), "not a pdf");
        lenient().when(documentService.findByOriginalFilename(any())).thenReturn(Optional.empty());
        Path xlsx = writeDocs(tempDir, docRow("W-0001", "", "", "", "", "", "", "", ""));

        DocumentImporter.LoadResult result = importer.load(xlsx.toFile());

        assertThat(result.skippedFiles())
                .extracting(ImportStatus.SkippedFile::reason)
                .containsExactly(ImportStatus.SkipReason.INVALID_PDF_SIGNATURE);
        verify(s3Client, never()).putObject(any(PutObjectRequest.class), any(RequestBody.class));
    }

    @Test
    void load_skipsFile_whenMagicByteCheckThrowsIoException(@TempDir Path tempDir) throws Exception {
        ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
        Files.writeString(tempDir.resolve("W-0001.pdf"), "content");
        lenient().when(documentService.findByOriginalFilename(any())).thenReturn(Optional.empty());
        Path xlsx = writeDocs(tempDir, docRow("W-0001", "", "", "", "", "", "", "", ""));

        // FileStreamOpener is injected — stub it to throw, no spy on the importer needed.
        org.mockito.Mockito.when(fileStreamOpener.open(any(File.class)))
                .thenThrow(new java.io.IOException("read error"));

        DocumentImporter.LoadResult result = importer.load(xlsx.toFile());

        assertThat(result.skippedFiles())
                .extracting(ImportStatus.SkippedFile::reason)
                .containsExactly(ImportStatus.SkipReason.FILE_READ_ERROR);
    }

    @Test
    void load_skipsAlreadyExists_whenDocumentUploadedNotPlaceholder(@TempDir Path tempDir) throws Exception {
        ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
        Document existing = Document.builder().id(UUID.randomUUID())
                .originalFilename("W-0001").status(DocumentStatus.UPLOADED).build();
        when(documentService.findByOriginalFilename("W-0001")).thenReturn(Optional.of(existing));
        Path xlsx = writeDocs(tempDir, docRow("W-0001", "", "", "", "", "", "", "", ""));

        DocumentImporter.LoadResult result = importer.load(xlsx.toFile());

        assertThat(result.skippedFiles())
                .extracting(ImportStatus.SkippedFile::reason)
                .containsExactly(ImportStatus.SkipReason.ALREADY_EXISTS);
        verify(documentService, never()).save(any());
    }

    // ─── presence of importDir/<index>.pdf drives status: present → UPLOADED, absent → PLACEHOLDER ─

    @Test
    void load_setsStatusPlaceholder_whenNoIndexedPdf(@TempDir Path tempDir) throws Exception {
        ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
        when(documentService.findByOriginalFilename("W-0099")).thenReturn(Optional.empty());
        when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
        Path xlsx = writeDocs(tempDir, docRow("W-0099", "", "", "", "", "", "", "", ""));

        importer.load(xlsx.toFile());

        verify(documentService).save(org.mockito.ArgumentMatchers.argThat(d -> d.getStatus() == DocumentStatus.PLACEHOLDER));
        verify(s3Client, never()).putObject(any(PutObjectRequest.class), any(RequestBody.class));
    }

    // ─── attribution routing — register-first + always retain raw ────────────────────

    @Test
    void load_linksRegisterSender_andRetainsRawSenderText(@TempDir Path tempDir) throws Exception {
        ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
        Person walter = Person.builder().id(UUID.randomUUID()).sourceRef("de-gruyter-walter")
                .firstName("Walter").lastName("de Gruyter").build();
        when(documentService.findByOriginalFilename("W-0001")).thenReturn(Optional.empty());
        when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
        when(personService.findBySourceRef("de-gruyter-walter")).thenReturn(Optional.of(walter));
        Path xlsx = writeDocs(tempDir, docRow("W-0001", "de-gruyter-walter", "Walter de Gruyter",
                "", "", "", "", "", ""));

        importer.load(xlsx.toFile());

        verify(documentService).save(org.mockito.ArgumentMatchers.argThat(d ->
                d.getSender() == walter && "Walter de Gruyter".equals(d.getSenderText())));
    }

    @Test
    void load_createsProvisionalSender_whenSlugUnmatchedInRegister(@TempDir Path tempDir) throws Exception {
        ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
        Person provisional = Person.builder().id(UUID.randomUUID()).sourceRef("schwester-hanni")
                .lastName("Schwester Hanni").provisional(true).build();
        when(documentService.findByOriginalFilename("W-0002")).thenReturn(Optional.empty());
        when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
        when(personService.findBySourceRef("schwester-hanni")).thenReturn(Optional.empty());
        when(personService.upsertBySourceRef(any())).thenReturn(provisional);
        Path xlsx = writeDocs(tempDir, docRow("W-0002", "schwester-hanni", "Schwester Hanni",
                "", "", "", "", "", ""));

        importer.load(xlsx.toFile());

        org.mockito.ArgumentCaptor<PersonUpsertCommand> captor =
                org.mockito.ArgumentCaptor.forClass(PersonUpsertCommand.class);
        verify(personService).upsertBySourceRef(captor.capture());
        assertThat(captor.getValue().provisional()).isTrue();
        assertThat(captor.getValue().lastName()).isEqualTo("Schwester Hanni");
    }

    @Test
    void load_createsNoSenderPerson_whenSlugEmptyButRawPresent(@TempDir Path tempDir) throws Exception {
        ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
        when(documentService.findByOriginalFilename("W-0003")).thenReturn(Optional.empty());
        when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
        Path xlsx = writeDocs(tempDir, docRow("W-0003", "", "?",
                "", "", "", "", "", ""));

        importer.load(xlsx.toFile());

        verify(personService, never()).findBySourceRef(any());
        verify(personService, never()).upsertBySourceRef(any());
        verify(documentService).save(org.mockito.ArgumentMatchers.argThat(d ->
                d.getSender() == null && "?".equals(d.getSenderText())));
    }

    @Test
    void load_splitsMultipleReceivers_andRetainsRawReceiverText(@TempDir Path tempDir) throws Exception {
        ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
        Person herbert = Person.builder().id(UUID.randomUUID()).sourceRef("cram-herbert").lastName("Cram").build();
        Person clara = Person.builder().id(UUID.randomUUID()).sourceRef("clara").lastName("Clara").build();
        when(documentService.findByOriginalFilename("W-0004")).thenReturn(Optional.empty());
        when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
        when(personService.findBySourceRef("cram-herbert")).thenReturn(Optional.of(herbert));
        when(personService.findBySourceRef("clara")).thenReturn(Optional.of(clara));
        Path xlsx = writeDocs(tempDir, docRow("W-0004", "", "",
                "cram-herbert|clara", "Herbert Cram|Clara", "", "", "", ""));

        importer.load(xlsx.toFile());

        verify(documentService).save(org.mockito.ArgumentMatchers.argThat(d ->
                d.getReceivers().size() == 2
                        && d.getReceivers().contains(herbert)
                        && d.getReceivers().contains(clara)
                        && "Herbert Cram|Clara".equals(d.getReceiverText())));
    }

    @Test
    void load_provisionalReceiverUsesHumanNameFromReceiverNames_notSlug(@TempDir Path tempDir) throws Exception {
        // Regression: resolveReceivers used to pass the slug as both `sourceRef` AND `lastName`,
        // so an unresolved receiver "smith-john" became a provisional Person with
        // lastName="smith-john". The fix consumes the parallel `receiver_names` column.
        ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
        Person provisional = Person.builder().id(UUID.randomUUID()).sourceRef("smith-john")
                .lastName("John Smith").provisional(true).build();
        when(documentService.findByOriginalFilename("W-0050")).thenReturn(Optional.empty());
        when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
        when(personService.findBySourceRef("smith-john")).thenReturn(Optional.empty());
        when(personService.upsertBySourceRef(any())).thenReturn(provisional);
        Path xlsx = writeDocs(tempDir, docRow("W-0050", "", "",
                "smith-john", "John Smith", "", "", "", ""));

        importer.load(xlsx.toFile());

        org.mockito.ArgumentCaptor<PersonUpsertCommand> captor =
                org.mockito.ArgumentCaptor.forClass(PersonUpsertCommand.class);
        verify(personService).upsertBySourceRef(captor.capture());
        assertThat(captor.getValue().sourceRef()).isEqualTo("smith-john");
        assertThat(captor.getValue().lastName()).isEqualTo("John Smith");
        assertThat(captor.getValue().provisional()).isTrue();
    }

    @Test
    void load_provisionalReceiverFallsBackToSlug_whenNamesListShorterThanSlugs(@TempDir Path tempDir) throws Exception {
        // Parallel-list zip: if the names list is shorter than the slugs list, slugs without a
        // matching name fall back to slug as the display name. This is the "missing name" case
        // (rare in canonical data but the contract must define it).
        ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
        Person alice = Person.builder().id(UUID.randomUUID()).sourceRef("alice-jones")
                .lastName("Alice Jones").provisional(true).build();
        Person bob = Person.builder().id(UUID.randomUUID()).sourceRef("bob-roe")
                .lastName("bob-roe").provisional(true).build();
        when(documentService.findByOriginalFilename("W-0051")).thenReturn(Optional.empty());
        when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
        when(personService.findBySourceRef("alice-jones")).thenReturn(Optional.empty());
        when(personService.findBySourceRef("bob-roe")).thenReturn(Optional.empty());
        when(personService.upsertBySourceRef(any())).thenReturn(alice).thenReturn(bob);
        Path xlsx = writeDocs(tempDir, docRow("W-0051", "", "",
                "alice-jones|bob-roe", "Alice Jones", "", "", "", ""));

        importer.load(xlsx.toFile());

        org.mockito.ArgumentCaptor<PersonUpsertCommand> captor =
                org.mockito.ArgumentCaptor.forClass(PersonUpsertCommand.class);
        verify(personService, org.mockito.Mockito.times(2)).upsertBySourceRef(captor.capture());
        assertThat(captor.getAllValues()).extracting(PersonUpsertCommand::sourceRef)
                .containsExactly("alice-jones", "bob-roe");
        assertThat(captor.getAllValues()).extracting(PersonUpsertCommand::lastName)
                .containsExactly("Alice Jones", "bob-roe");
    }

    // ─── clean date values parse without semantic logic ──────────────────────────────

    @Test
    void load_parsesCleanDateAndPrecision(@TempDir Path tempDir) throws Exception {
        ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
        when(documentService.findByOriginalFilename("W-0005")).thenReturn(Optional.empty());
        when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
        Path xlsx = writeDocs(tempDir, docRow("W-0005", "", "",
                "", "", "1916-06-01", "1.6.1916", "MONTH", ""));

        importer.load(xlsx.toFile());

        verify(documentService).save(org.mockito.ArgumentMatchers.argThat(d ->
                LocalDate.of(1916, 6, 1).equals(d.getDocumentDate())
                        && d.getMetaDatePrecision() == org.raddatz.familienarchiv.document.DatePrecision.MONTH
                        && "1.6.1916".equals(d.getMetaDateRaw())));
    }

    @Test
    void load_attachesTagBySourceRef(@TempDir Path tempDir) throws Exception {
        ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
        Tag tag = Tag.builder().id(UUID.randomUUID()).name("Brautbriefe").sourceRef("Themen/Brautbriefe").build();
        when(documentService.findByOriginalFilename("W-0006")).thenReturn(Optional.empty());
        when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
        when(tagService.findBySourceRef("Themen/Brautbriefe")).thenReturn(Optional.of(tag));
        Path xlsx = writeDocs(tempDir, docRowWithTag("W-0006", "Themen/Brautbriefe"));

        importer.load(xlsx.toFile());

        verify(documentService).save(org.mockito.ArgumentMatchers.argThat(d -> d.getTags().contains(tag)));
    }

    // ─── idempotency — update existing document in place by index ─────────────────────

    @Test
    void load_updatesExistingDocumentInPlace_whenIndexExists(@TempDir Path tempDir) throws Exception {
        ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
        Document existing = Document.builder().id(UUID.randomUUID())
                .originalFilename("W-0007").status(DocumentStatus.PLACEHOLDER).build();
        when(documentService.findByOriginalFilename("W-0007")).thenReturn(Optional.of(existing));
        when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
        Path xlsx = writeDocs(tempDir, docRow("W-0007", "", "", "", "", "", "", "", ""));

        importer.load(xlsx.toFile());

        verify(documentService).save(org.mockito.ArgumentMatchers.argThat(d -> d.getId().equals(existing.getId())));
    }

    // ─── canonical collections are authoritative — re-import prunes removed links ──────

    @Test
    void load_prunesReceiversAndTags_whenCanonicalRowShrinks(@TempDir Path tempDir) throws Exception {
        ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
        Person staleReceiver = Person.builder().id(UUID.randomUUID()).sourceRef("stale-receiver").lastName("Stale").build();
        Tag staleTag = Tag.builder().id(UUID.randomUUID()).name("Stale").sourceRef("Themen/Stale").build();
        Document existing = Document.builder().id(UUID.randomUUID())
                .originalFilename("W-0008").status(DocumentStatus.PLACEHOLDER).build();
        existing.getReceivers().add(staleReceiver);
        existing.getTags().add(staleTag);
        when(documentService.findByOriginalFilename("W-0008")).thenReturn(Optional.of(existing));
        when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
        // The canonical row now carries no receiver and no tag: both stale links must go.
        Path xlsx = writeDocs(tempDir, docRow("W-0008", "", "", "", "", "", "", "", ""));

        importer.load(xlsx.toFile());

        verify(documentService).save(org.mockito.ArgumentMatchers.argThat(d ->
                d.getReceivers().isEmpty() && d.getTags().isEmpty()));
    }

    // ─── title carries the honest date label — never a precision the data lacks ───────

    @Test
    void load_buildsTitleWithMonthLabel_whenPrecisionIsMonth(@TempDir Path tempDir) throws Exception {
        ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
        when(documentService.findByOriginalFilename("W-0100")).thenReturn(Optional.empty());
        when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
        Path xlsx = writeDocs(tempDir, docRow("W-0100", "", "", "", "",
                "1916-06-01", "Juni 1916", "MONTH", ""));

        importer.load(xlsx.toFile());

        verify(documentService).save(org.mockito.ArgumentMatchers.argThat(d ->
                d.getTitle().contains("Juni 1916") && !d.getTitle().contains("1. Juni")));
    }

    @Test
    void load_buildsTitleWithFullDate_whenPrecisionIsDay(@TempDir Path tempDir) throws Exception {
        ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
        when(documentService.findByOriginalFilename("W-0101")).thenReturn(Optional.empty());
        when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
        Path xlsx = writeDocs(tempDir, docRow("W-0101", "", "", "", "",
                "1943-12-24", "24.12.1943", "DAY", ""));

        importer.load(xlsx.toFile());

        verify(documentService).save(org.mockito.ArgumentMatchers.argThat(d ->
                d.getTitle().contains("24. Dezember 1943")));
    }

    @Test
    void load_buildsTitleFromIndexOnly_whenDateUnknown(@TempDir Path tempDir) throws Exception {
        ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
        when(documentService.findByOriginalFilename("W-0102")).thenReturn(Optional.empty());
        when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
        Path xlsx = writeDocs(tempDir, docRow("W-0102", "", "", "", "",
                "", "?", "UNKNOWN", ""));

        importer.load(xlsx.toFile());

        verify(documentService).save(org.mockito.ArgumentMatchers.argThat(d ->
                d.getTitle().equals("W-0102")));
    }

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

    private Boolean validIndex(String index) {
        return ReflectionTestUtils.invokeMethod(importer, "isValidImportIndex", index);
    }

    private Map<String, String> docRow(String index, String senderId, String senderName,
                                        String receiverIds, String receiverNames, String dateIso,
                                        String dateRaw, String datePrecision, String dateEnd) {
        Map<String, String> r = new LinkedHashMap<>();
        r.put("index", index);
        r.put("sender_person_id", senderId);
        r.put("sender_name", senderName);
        r.put("receiver_person_ids", receiverIds);
        r.put("receiver_names", receiverNames);
        r.put("date_iso", dateIso);
        r.put("date_raw", dateRaw);
        r.put("date_precision", datePrecision);
        r.put("date_end", dateEnd);
        r.put("location", "");
        r.put("tags", "");
        r.put("summary", "");
        return r;
    }

    private Map<String, String> docRowWithTag(String index, String tagPath) {
        Map<String, String> r = docRow(index, "", "", "", "", "", "", "", "");
        r.put("tags", tagPath);
        return r;
    }

    @SafeVarargs
    private Path writeDocs(Path dir, Map<String, String>... rows) throws Exception {
        Path xlsx = dir.resolve("canonical-documents.xlsx");
        List<String> headers = List.of("index", "sender_person_id", "sender_name",
                "receiver_person_ids", "receiver_names", "date_iso", "date_raw", "date_precision",
                "date_end", "location", "tags", "summary");
        try (XSSFWorkbook wb = new XSSFWorkbook()) {
            Sheet sheet = wb.createSheet("Sheet1");
            Row header = sheet.createRow(0);
            for (int i = 0; i < headers.size(); i++) {
                header.createCell(i).setCellValue(headers.get(i));
            }
            for (int r = 0; r < rows.length; r++) {
                Row row = sheet.createRow(r + 1);
                for (int c = 0; c < headers.size(); c++) {
                    row.createCell(c).setCellValue(rows[r].getOrDefault(headers.get(c), ""));
                }
            }
            try (OutputStream out = Files.newOutputStream(xlsx)) {
                wb.write(out);
            }
        }
        return xlsx;
    }
}
