package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import java.nio.file.Path;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.time.Instant;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.List;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;

import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
import de.gecheckt.pdf.umbenenner.domain.model.DateSource;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
import de.gecheckt.pdf.umbenenner.domain.model.RunId;

/**
 * Tests for {@link SqliteProcessingAttemptRepositoryAdapter}.
 * <p>
 * Covers base attempt persistence, AI traceability field round-trips,
 * proposal-ready lookup, and non-AI-attempt status storability.
 */
class SqliteProcessingAttemptRepositoryAdapterTest {

    private SqliteProcessingAttemptRepositoryAdapter repository;
    private String jdbcUrl;

    @TempDir
    Path tempDir;

    @BeforeEach
    void setUp() {
        Path dbFile = tempDir.resolve("test.db");
        jdbcUrl = "jdbc:sqlite:" + dbFile.toAbsolutePath();

        // Initialize schema first
        SqliteSchemaInitializationAdapter schemaInitializer =
                new SqliteSchemaInitializationAdapter(jdbcUrl);
        schemaInitializer.initializeSchema();

        repository = new SqliteProcessingAttemptRepositoryAdapter(jdbcUrl);
    }

    // -------------------------------------------------------------------------
    // Construction
    // -------------------------------------------------------------------------

    @Test
    void constructor_rejectsNullJdbcUrl() {
        assertThatThrownBy(() -> new SqliteProcessingAttemptRepositoryAdapter(null))
                .isInstanceOf(NullPointerException.class)
                .hasMessageContaining("jdbcUrl");
    }

    @Test
    void constructor_rejectsBlankJdbcUrl() {
        assertThatThrownBy(() -> new SqliteProcessingAttemptRepositoryAdapter("   "))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("jdbcUrl");
    }

    @Test
    void getJdbcUrl_returnsConfiguredUrl() {
        String url = "jdbc:sqlite:/some/path/test.db";
        SqliteProcessingAttemptRepositoryAdapter adapter = new SqliteProcessingAttemptRepositoryAdapter(url);
        assertThat(adapter.getJdbcUrl()).isEqualTo(url);
    }

    // -------------------------------------------------------------------------
    // loadNextAttemptNumber
    // -------------------------------------------------------------------------

    @Test
    void loadNextAttemptNumber_shouldReturnOne_whenNoAttemptsExist() {
        // Given
        DocumentFingerprint fingerprint = new DocumentFingerprint(
                "0000000000000000000000000000000000000000000000000000000000000000");

        // When
        int nextAttemptNumber = repository.loadNextAttemptNumber(fingerprint);

        // Then
        assertThat(nextAttemptNumber).isEqualTo(1);
    }

    @Test
    void loadNextAttemptNumber_shouldReturnNextNumber_whenAttemptsExist() {
        // Given
        DocumentFingerprint fingerprint = new DocumentFingerprint(
                "1111111111111111111111111111111111111111111111111111111111111111");
        RunId runId = new RunId("test-run-1");
        Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);

        // Insert a document record first (FK constraint)
        insertDocumentRecord(fingerprint);

        // Insert first attempt
        ProcessingAttempt firstAttempt = ProcessingAttempt.withoutAiFields(
                fingerprint,
                runId,
                1,
                now,
                now.plusSeconds(10),
                ProcessingStatus.FAILED_RETRYABLE,
                "IOException",
                "File not found",
                true
        );
        repository.save(firstAttempt);

        // When
        int nextAttemptNumber = repository.loadNextAttemptNumber(fingerprint);

        // Then
        assertThat(nextAttemptNumber).isEqualTo(2);
    }

    @Test
    void loadNextAttemptNumber_shouldBeMonotonicAcrossMultipleCalls() {
        // Given
        DocumentFingerprint fingerprint = new DocumentFingerprint(
                "2222222222222222222222222222222222222222222222222222222222222222");
        RunId runId = new RunId("test-run-2");
        Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);

        // Insert a document record first (FK constraint)
        insertDocumentRecord(fingerprint);

        // Insert multiple attempts
        for (int i = 1; i <= 5; i++) {
            ProcessingAttempt attempt = ProcessingAttempt.withoutAiFields(
                    fingerprint,
                    runId,
                    i,
                    now.plusSeconds(i * 10),
                    now.plusSeconds(i * 10 + 5),
                    ProcessingStatus.FAILED_RETRYABLE,
                    "IOException",
                    "File not found",
                    true
            );
            repository.save(attempt);
        }

        // When
        int nextAttemptNumber = repository.loadNextAttemptNumber(fingerprint);

        // Then
        assertThat(nextAttemptNumber).isEqualTo(6);
    }

    @Test
    void loadNextAttemptNumber_shouldRejectNullFingerprint() {
        assertThatThrownBy(() -> repository.loadNextAttemptNumber(null))
                .isInstanceOf(NullPointerException.class)
                .hasMessageContaining("fingerprint");
    }

    // -------------------------------------------------------------------------
    // save
    // -------------------------------------------------------------------------

    @Test
    void save_shouldPersistProcessingAttempt_withAllFields() {
        // Given
        DocumentFingerprint fingerprint = new DocumentFingerprint(
                "3333333333333333333333333333333333333333333333333333333333333333");
        RunId runId = new RunId("test-run-3");
        Instant startedAt = Instant.now().minusSeconds(60).truncatedTo(ChronoUnit.MICROS);
        Instant endedAt = Instant.now().truncatedTo(ChronoUnit.MICROS);

        // Insert a document record first (FK constraint)
        insertDocumentRecord(fingerprint);

        ProcessingAttempt attempt = ProcessingAttempt.withoutAiFields(
                fingerprint,
                runId,
                1,
                startedAt,
                endedAt,
                ProcessingStatus.FAILED_RETRYABLE,
                "IOException",
                "File not found",
                true
        );

        // When
        repository.save(attempt);

        // Then
        List<ProcessingAttempt> attempts = repository.findAllByFingerprint(fingerprint);
        assertThat(attempts).hasSize(1);

        ProcessingAttempt saved = attempts.get(0);
        assertThat(saved.fingerprint()).isEqualTo(fingerprint);
        assertThat(saved.runId()).isEqualTo(runId);
        assertThat(saved.attemptNumber()).isEqualTo(1);
        assertThat(saved.startedAt()).isEqualTo(startedAt);
        assertThat(saved.endedAt()).isEqualTo(endedAt);
        assertThat(saved.status()).isEqualTo(ProcessingStatus.FAILED_RETRYABLE);
        assertThat(saved.failureClass()).isEqualTo("IOException");
        assertThat(saved.failureMessage()).isEqualTo("File not found");
        assertThat(saved.retryable()).isTrue();
    }

    @Test
    void save_shouldPersistProcessingAttempt_withNullFailureFields() {
        // Given
        DocumentFingerprint fingerprint = new DocumentFingerprint(
                "4444444444444444444444444444444444444444444444444444444444444444");
        RunId runId = new RunId("test-run-4");
        Instant startedAt = Instant.now().minusSeconds(60).truncatedTo(ChronoUnit.MICROS);
        Instant endedAt = Instant.now().truncatedTo(ChronoUnit.MICROS);

        // Insert a document record first (FK constraint)
        insertDocumentRecord(fingerprint);

        ProcessingAttempt attempt = ProcessingAttempt.withoutAiFields(
                fingerprint,
                runId,
                1,
                startedAt,
                endedAt,
                ProcessingStatus.SUCCESS,
                null, // null failure class
                null, // null failure message
                false // not retryable
        );

        // When
        repository.save(attempt);

        // Then
        List<ProcessingAttempt> attempts = repository.findAllByFingerprint(fingerprint);
        assertThat(attempts).hasSize(1);

        ProcessingAttempt saved = attempts.get(0);
        assertThat(saved.failureClass()).isNull();
        assertThat(saved.failureMessage()).isNull();
        assertThat(saved.retryable()).isFalse();
    }

    @Test
    void save_shouldRejectNullAttempt() {
        assertThatThrownBy(() -> repository.save(null))
                .isInstanceOf(NullPointerException.class)
                .hasMessageContaining("attempt");
    }

    // -------------------------------------------------------------------------
    // findAllByFingerprint
    // -------------------------------------------------------------------------

    @Test
    void findAllByFingerprint_shouldReturnEmptyList_whenNoAttemptsExist() {
        // Given
        DocumentFingerprint fingerprint = new DocumentFingerprint(
                "5555555555555555555555555555555555555555555555555555555555555555");

        // When
        List<ProcessingAttempt> attempts = repository.findAllByFingerprint(fingerprint);

        // Then
        assertThat(attempts).isEmpty();
    }

    @Test
    void findAllByFingerprint_shouldReturnAllAttemptsOrderedByNumber() {
        // Given
        DocumentFingerprint fingerprint = new DocumentFingerprint(
                "6666666666666666666666666666666666666666666666666666666666666666");
        RunId runId1 = new RunId("test-run-5");
        RunId runId2 = new RunId("test-run-6");
        Instant baseTime = Instant.now().truncatedTo(ChronoUnit.MICROS);

        // Insert a document record first (FK constraint)
        insertDocumentRecord(fingerprint);

        // Insert attempts out of order to verify sorting
        ProcessingAttempt attempt3 = ProcessingAttempt.withoutAiFields(
                fingerprint,
                runId2,
                3,
                baseTime.plusSeconds(20),
                baseTime.plusSeconds(25),
                ProcessingStatus.FAILED_FINAL,
                "ContentError",
                "No text extractable",
                false
        );
        repository.save(attempt3);

        ProcessingAttempt attempt1 = ProcessingAttempt.withoutAiFields(
                fingerprint,
                runId1,
                1,
                baseTime,
                baseTime.plusSeconds(5),
                ProcessingStatus.FAILED_RETRYABLE,
                "IOException",
                "File not found",
                true
        );
        repository.save(attempt1);

        ProcessingAttempt attempt2 = ProcessingAttempt.withoutAiFields(
                fingerprint,
                runId1,
                2,
                baseTime.plusSeconds(10),
                baseTime.plusSeconds(15),
                ProcessingStatus.SKIPPED_ALREADY_PROCESSED,
                null,
                null,
                false
        );
        repository.save(attempt2);

        // When
        List<ProcessingAttempt> attempts = repository.findAllByFingerprint(fingerprint);

        // Then
        assertThat(attempts).hasSize(3);
        assertThat(attempts.get(0).attemptNumber()).isEqualTo(1);
        assertThat(attempts.get(1).attemptNumber()).isEqualTo(2);
        assertThat(attempts.get(2).attemptNumber()).isEqualTo(3);

        // Verify all fields
        ProcessingAttempt first = attempts.get(0);
        assertThat(first.runId()).isEqualTo(runId1);
        assertThat(first.status()).isEqualTo(ProcessingStatus.FAILED_RETRYABLE);
        assertThat(first.failureClass()).isEqualTo("IOException");

        ProcessingAttempt second = attempts.get(1);
        assertThat(second.status()).isEqualTo(ProcessingStatus.SKIPPED_ALREADY_PROCESSED);
        assertThat(second.failureClass()).isNull();

        ProcessingAttempt third = attempts.get(2);
        assertThat(third.status()).isEqualTo(ProcessingStatus.FAILED_FINAL);
        assertThat(third.retryable()).isFalse();
    }

    @Test
    void findAllByFingerprint_shouldReturnImmutableList() {
        // Given
        DocumentFingerprint fingerprint = new DocumentFingerprint(
                "7777777777777777777777777777777777777777777777777777777777777777");

        // When
        List<ProcessingAttempt> attempts = repository.findAllByFingerprint(fingerprint);

        // Then
        assertThat(attempts).isEmpty();
        assertThatThrownBy(() -> attempts.add(null))
                .isInstanceOf(UnsupportedOperationException.class);
    }

    @Test
    void findAllByFingerprint_shouldRejectNullFingerprint() {
        assertThatThrownBy(() -> repository.findAllByFingerprint(null))
                .isInstanceOf(NullPointerException.class)
                .hasMessageContaining("fingerprint");
    }

    // -------------------------------------------------------------------------
    // AI traceability fields — round-trip persistence
    // -------------------------------------------------------------------------

    @Test
    void save_persistsAllAiTraceabilityFields_andFindAllReadsThemBack() {
        // Given
        DocumentFingerprint fingerprint = new DocumentFingerprint(
                "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
        RunId runId = new RunId("ai-run-1");
        Instant startedAt = Instant.now().minusSeconds(30).truncatedTo(ChronoUnit.MICROS);
        Instant endedAt = Instant.now().truncatedTo(ChronoUnit.MICROS);
        LocalDate resolvedDate = LocalDate.of(2026, 3, 15);

        insertDocumentRecord(fingerprint);

        ProcessingAttempt attempt = new ProcessingAttempt(
                fingerprint, runId, 1, startedAt, endedAt,
                ProcessingStatus.PROPOSAL_READY,
                null, null, false,
                "openai-compatible",
                "gpt-4o", "prompt-v1.txt",
                5, 1234,
                "{\"date\":\"2026-03-15\",\"title\":\"Stromabrechnung\",\"reasoning\":\"Invoice date found.\"}",
                "Invoice date found.",
                resolvedDate, DateSource.AI_PROVIDED,
                "Stromabrechnung",
                null
        );

        // When
        repository.save(attempt);

        // Then
        List<ProcessingAttempt> saved = repository.findAllByFingerprint(fingerprint);
        assertThat(saved).hasSize(1);
        ProcessingAttempt result = saved.get(0);

        assertThat(result.modelName()).isEqualTo("gpt-4o");
        assertThat(result.promptIdentifier()).isEqualTo("prompt-v1.txt");
        assertThat(result.processedPageCount()).isEqualTo(5);
        assertThat(result.sentCharacterCount()).isEqualTo(1234);
        assertThat(result.aiRawResponse()).contains("Stromabrechnung");
        assertThat(result.aiReasoning()).isEqualTo("Invoice date found.");
        assertThat(result.resolvedDate()).isEqualTo(resolvedDate);
        assertThat(result.dateSource()).isEqualTo(DateSource.AI_PROVIDED);
        assertThat(result.validatedTitle()).isEqualTo("Stromabrechnung");
    }

    @Test
    void save_persistsAiFieldsWithFallbackDateSource() {
        // Given
        DocumentFingerprint fingerprint = new DocumentFingerprint(
                "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
        RunId runId = new RunId("ai-run-2");
        Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);
        LocalDate fallbackDate = LocalDate.of(2026, 4, 7);

        insertDocumentRecord(fingerprint);

        ProcessingAttempt attempt = new ProcessingAttempt(
                fingerprint, runId, 1, now, now.plusSeconds(5),
                ProcessingStatus.PROPOSAL_READY,
                null, null, false,
                "openai-compatible",
                "claude-sonnet-4-6", "prompt-v2.txt",
                3, 800,
                "{\"title\":\"Kontoauszug\",\"reasoning\":\"No date in document.\"}",
                "No date in document.",
                fallbackDate, DateSource.FALLBACK_CURRENT,
                "Kontoauszug",
                null
        );

        repository.save(attempt);

        List<ProcessingAttempt> saved = repository.findAllByFingerprint(fingerprint);
        assertThat(saved).hasSize(1);
        ProcessingAttempt result = saved.get(0);

        assertThat(result.dateSource()).isEqualTo(DateSource.FALLBACK_CURRENT);
        assertThat(result.resolvedDate()).isEqualTo(fallbackDate);
    }

    @Test
    void save_persistsNullAiFields_whenNoAiCallWasMade() {
        // Given
        DocumentFingerprint fingerprint = new DocumentFingerprint(
                "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc");
        RunId runId = new RunId("no-ai-run");
        Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);

        insertDocumentRecord(fingerprint);

        ProcessingAttempt attempt = ProcessingAttempt.withoutAiFields(
                fingerprint, runId, 1, now, now.plusSeconds(1),
                ProcessingStatus.FAILED_RETRYABLE,
                "NoTextError", "No extractable text", true
        );

        repository.save(attempt);

        List<ProcessingAttempt> saved = repository.findAllByFingerprint(fingerprint);
        assertThat(saved).hasSize(1);
        ProcessingAttempt result = saved.get(0);

        assertThat(result.modelName()).isNull();
        assertThat(result.promptIdentifier()).isNull();
        assertThat(result.processedPageCount()).isNull();
        assertThat(result.sentCharacterCount()).isNull();
        assertThat(result.aiRawResponse()).isNull();
        assertThat(result.aiReasoning()).isNull();
        assertThat(result.resolvedDate()).isNull();
        assertThat(result.dateSource()).isNull();
        assertThat(result.validatedTitle()).isNull();
    }

    // -------------------------------------------------------------------------
    // findLatestProposalReadyAttempt
    // -------------------------------------------------------------------------

    @Test
    void findLatestProposalReadyAttempt_returnsNull_whenNoAttemptsExist() {
        DocumentFingerprint fingerprint = new DocumentFingerprint(
                "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd");

        ProcessingAttempt result = repository.findLatestProposalReadyAttempt(fingerprint);

        assertThat(result).isNull();
    }

    @Test
    void findLatestProposalReadyAttempt_returnsNull_whenNoProposalReadyAttemptExists() {
        DocumentFingerprint fingerprint = new DocumentFingerprint(
                "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee");
        Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);

        insertDocumentRecord(fingerprint);
        ProcessingAttempt attempt = ProcessingAttempt.withoutAiFields(
                fingerprint, new RunId("run-x"), 1, now, now.plusSeconds(1),
                ProcessingStatus.FAILED_RETRYABLE, "Err", "msg", true
        );
        repository.save(attempt);

        ProcessingAttempt result = repository.findLatestProposalReadyAttempt(fingerprint);

        assertThat(result).isNull();
    }

    @Test
    void findLatestProposalReadyAttempt_returnsSingleProposalReadyAttempt() {
        DocumentFingerprint fingerprint = new DocumentFingerprint(
                "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff");
        Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);
        LocalDate date = LocalDate.of(2026, 2, 1);

        insertDocumentRecord(fingerprint);

        ProcessingAttempt attempt = new ProcessingAttempt(
                fingerprint, new RunId("run-p"), 1, now, now.plusSeconds(2),
                ProcessingStatus.PROPOSAL_READY,
                null, null, false,
                null,
                "gpt-4o", "prompt-v1.txt", 2, 500,
                "{\"title\":\"Rechnung\",\"reasoning\":\"Found.\"}",
                "Found.", date, DateSource.AI_PROVIDED, "Rechnung",
                null
        );
        repository.save(attempt);

        ProcessingAttempt result = repository.findLatestProposalReadyAttempt(fingerprint);

        assertThat(result).isNotNull();
        assertThat(result.status()).isEqualTo(ProcessingStatus.PROPOSAL_READY);
        assertThat(result.validatedTitle()).isEqualTo("Rechnung");
        assertThat(result.resolvedDate()).isEqualTo(date);
        assertThat(result.dateSource()).isEqualTo(DateSource.AI_PROVIDED);
    }

    @Test
    void findLatestProposalReadyAttempt_returnsLatest_whenMultipleExist() {
        DocumentFingerprint fingerprint = new DocumentFingerprint(
                "1111111111111111111111111111111111111111111111111111111111111112");
        Instant base = Instant.now().truncatedTo(ChronoUnit.MICROS);

        insertDocumentRecord(fingerprint);

        // First PROPOSAL_READY attempt
        repository.save(new ProcessingAttempt(
                fingerprint, new RunId("run-1"), 1, base, base.plusSeconds(1),
                ProcessingStatus.PROPOSAL_READY,
                null, null, false,
                null,
                "model-a", "prompt-v1.txt", 1, 100,
                "{}", "First.", LocalDate.of(2026, 1, 1), DateSource.AI_PROVIDED, "TitelEins",
                null
        ));

        // Subsequent FAILED attempt
        repository.save(ProcessingAttempt.withoutAiFields(
                fingerprint, new RunId("run-2"), 2,
                base.plusSeconds(10), base.plusSeconds(11),
                ProcessingStatus.FAILED_RETRYABLE, "Err", "msg", true
        ));

        // Second PROPOSAL_READY attempt (newer)
        repository.save(new ProcessingAttempt(
                fingerprint, new RunId("run-3"), 3, base.plusSeconds(20), base.plusSeconds(21),
                ProcessingStatus.PROPOSAL_READY,
                null, null, false,
                null,
                "model-b", "prompt-v2.txt", 2, 200,
                "{}", "Second.", LocalDate.of(2026, 2, 2), DateSource.AI_PROVIDED, "TitelZwei",
                null
        ));

        ProcessingAttempt result = repository.findLatestProposalReadyAttempt(fingerprint);

        assertThat(result).isNotNull();
        assertThat(result.attemptNumber()).isEqualTo(3);
        assertThat(result.validatedTitle()).isEqualTo("TitelZwei");
        assertThat(result.modelName()).isEqualTo("model-b");
    }

    @Test
    void save_persistsFinalTargetFileName_forSuccessAttempt() {
        // Given
        DocumentFingerprint fingerprint = new DocumentFingerprint(
                "4444444444444444444444444444444444444444444444444444444444444445");
        RunId runId = new RunId("success-run");
        Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);
        LocalDate date = LocalDate.of(2026, 1, 15);
        String expectedFileName = "2026-01-15 - Rechnung.pdf";

        insertDocumentRecord(fingerprint);

        ProcessingAttempt attempt = new ProcessingAttempt(
                fingerprint, runId, 1, now, now.plusSeconds(3),
                ProcessingStatus.SUCCESS,
                null, null, false,
                null,
                "gpt-4", "prompt-v1.txt", 2, 600,
                "{\"title\":\"Rechnung\",\"reasoning\":\"Invoice.\"}",
                "Invoice.",
                date, DateSource.AI_PROVIDED,
                "Rechnung",
                expectedFileName
        );

        // When
        repository.save(attempt);

        // Then
        List<ProcessingAttempt> saved = repository.findAllByFingerprint(fingerprint);
        assertThat(saved).hasSize(1);
        assertThat(saved.get(0).finalTargetFileName()).isEqualTo(expectedFileName);
        assertThat(saved.get(0).status()).isEqualTo(ProcessingStatus.SUCCESS);
    }

    @Test
    void save_persistsNullFinalTargetFileName_forNonSuccessAttempt() {
        // finalTargetFileName must remain null for PROPOSAL_READY and non-SUCCESS attempts
        DocumentFingerprint fingerprint = new DocumentFingerprint(
                "5555555555555555555555555555555555555555555555555555555555555556");
        Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);

        insertDocumentRecord(fingerprint);

        ProcessingAttempt attempt = new ProcessingAttempt(
                fingerprint, new RunId("run-prop"), 1, now, now.plusSeconds(1),
                ProcessingStatus.PROPOSAL_READY,
                null, null, false,
                null,
                "gpt-4", "prompt-v1.txt", 1, 200,
                "{}", "reason",
                LocalDate.of(2026, 3, 1), DateSource.AI_PROVIDED,
                "Kontoauszug",
                null   // no target filename yet
        );

        repository.save(attempt);

        List<ProcessingAttempt> saved = repository.findAllByFingerprint(fingerprint);
        assertThat(saved).hasSize(1);
        assertThat(saved.get(0).finalTargetFileName()).isNull();
    }

    @Test
    void save_proposalAttemptNotOverwrittenBySubsequentSuccessAttempt() {
        // Verifies that the leading PROPOSAL_READY attempt remains unchanged when
        // a subsequent SUCCESS attempt is added (no update, only new insert).
        DocumentFingerprint fingerprint = new DocumentFingerprint(
                "6666666666666666666666666666666666666666666666666666666666666667");
        Instant base = Instant.now().truncatedTo(ChronoUnit.MICROS);
        LocalDate date = LocalDate.of(2026, 2, 10);

        insertDocumentRecord(fingerprint);

        // First attempt: PROPOSAL_READY
        ProcessingAttempt proposalAttempt = new ProcessingAttempt(
                fingerprint, new RunId("run-1"), 1, base, base.plusSeconds(2),
                ProcessingStatus.PROPOSAL_READY,
                null, null, false,
                null,
                "model-a", "prompt-v1.txt", 3, 700,
                "{}", "reason.", date, DateSource.AI_PROVIDED, "Bescheid", null
        );
        repository.save(proposalAttempt);

        // Second attempt: SUCCESS (target copy completed)
        ProcessingAttempt successAttempt = new ProcessingAttempt(
                fingerprint, new RunId("run-1"), 2,
                base.plusSeconds(5), base.plusSeconds(6),
                ProcessingStatus.SUCCESS,
                null, null, false,
                null, null, null, null, null, null,
                null, null, null, null,
                "2026-02-10 - Bescheid.pdf"
        );
        repository.save(successAttempt);

        // Both attempts must be present
        List<ProcessingAttempt> all = repository.findAllByFingerprint(fingerprint);
        assertThat(all).hasSize(2);

        // The original PROPOSAL_READY attempt must remain unchanged
        ProcessingAttempt first = all.get(0);
        assertThat(first.status()).isEqualTo(ProcessingStatus.PROPOSAL_READY);
        assertThat(first.validatedTitle()).isEqualTo("Bescheid");
        assertThat(first.finalTargetFileName()).isNull();

        // The SUCCESS attempt carries the final filename
        ProcessingAttempt second = all.get(1);
        assertThat(second.status()).isEqualTo(ProcessingStatus.SUCCESS);
        assertThat(second.finalTargetFileName()).isEqualTo("2026-02-10 - Bescheid.pdf");
    }

    @Test
    void findLatestProposalReadyAttempt_rejectsNullFingerprint() {
        assertThatThrownBy(() -> repository.findLatestProposalReadyAttempt(null))
                .isInstanceOf(NullPointerException.class)
                .hasMessageContaining("fingerprint");
    }

    // -------------------------------------------------------------------------
    // READY_FOR_AI and PROPOSAL_READY status storability
    // -------------------------------------------------------------------------

    @Test
    void save_canPersistReadyForAiStatus() {
        DocumentFingerprint fingerprint = new DocumentFingerprint(
                "2222222222222222222222222222222222222222222222222222222222222223");
        Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);

        insertDocumentRecord(fingerprint);

        ProcessingAttempt attempt = ProcessingAttempt.withoutAiFields(
                fingerprint, new RunId("run-r"), 1, now, now.plusSeconds(1),
                ProcessingStatus.READY_FOR_AI, null, null, false
        );
        repository.save(attempt);

        List<ProcessingAttempt> saved = repository.findAllByFingerprint(fingerprint);
        assertThat(saved).hasSize(1);
        assertThat(saved.get(0).status()).isEqualTo(ProcessingStatus.READY_FOR_AI);
    }

    @Test
    void save_canPersistProposalReadyStatus() {
        DocumentFingerprint fingerprint = new DocumentFingerprint(
                "3333333333333333333333333333333333333333333333333333333333333334");
        Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);

        insertDocumentRecord(fingerprint);

        ProcessingAttempt attempt = new ProcessingAttempt(
                fingerprint, new RunId("run-p2"), 1, now, now.plusSeconds(1),
                ProcessingStatus.PROPOSAL_READY,
                null, null, false,
                null,
                "model-x", "prompt-v1.txt", 1, 50,
                "{}", "Reasoning.", LocalDate.of(2026, 1, 15), DateSource.AI_PROVIDED, "Titel",
                null
        );
        repository.save(attempt);

        List<ProcessingAttempt> saved = repository.findAllByFingerprint(fingerprint);
        assertThat(saved).hasSize(1);
        assertThat(saved.get(0).status()).isEqualTo(ProcessingStatus.PROPOSAL_READY);
    }

    // -------------------------------------------------------------------------
    // AI field persistence is independent of logging configuration
    // -------------------------------------------------------------------------

    /**
     * Verifies that the repository always stores the complete AI raw response and reasoning,
     * independent of any logging sensitivity configuration.
     * <p>
     * The {@code AiContentSensitivity} setting controls only whether sensitive content is
     * written to log files. It has no influence on what the repository persists. This test
     * demonstrates that full AI fields are stored regardless of any logging configuration by
     * verifying a round-trip with both full content and long reasoning text.
     */
    @Test
    void save_persistsFullAiResponseAndReasoning_unaffectedByLoggingConfiguration() {
        // The repository has no dependency on AiContentSensitivity.
        // It always stores the complete AI raw response and reasoning.
        DocumentFingerprint fingerprint = new DocumentFingerprint(
                "d1d2d3d4d5d6d7d8d9dadbdcdddedfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfd0".substring(0, 64));
        RunId runId = new RunId("persistence-independence-run");
        Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);

        // Deliberately long and complete AI raw response — must be stored in full
        String fullRawResponse = "{\"date\":\"2026-03-01\",\"title\":\"Stromabrechnung\","
                + "\"reasoning\":\"Invoice date clearly stated on page 1. Utility provider named.\"}";
        // Deliberately complete reasoning — must be stored in full
        String fullReasoning = "Invoice date clearly stated on page 1. Utility provider named.";

        insertDocumentRecord(fingerprint);

        ProcessingAttempt attempt = new ProcessingAttempt(
                fingerprint, runId, 1, now, now.plusSeconds(5),
                ProcessingStatus.PROPOSAL_READY,
                null, null, false,
                null,
                "gpt-4o", "prompt-v1.txt",
                3, 750,
                fullRawResponse,
                fullReasoning,
                LocalDate.of(2026, 3, 1), DateSource.AI_PROVIDED,
                "Stromabrechnung",
                null
        );

        repository.save(attempt);

        List<ProcessingAttempt> saved = repository.findAllByFingerprint(fingerprint);
        assertThat(saved).hasSize(1);
        ProcessingAttempt result = saved.get(0);

        // Full raw response is stored completely — not truncated, not suppressed
        assertThat(result.aiRawResponse()).isEqualTo(fullRawResponse);
        // Full reasoning is stored completely — not truncated, not suppressed
        assertThat(result.aiReasoning()).isEqualTo(fullReasoning);
    }

    // -------------------------------------------------------------------------
    // Integration with document records (FK constraints)
    // -------------------------------------------------------------------------

    @Test
    void save_shouldFail_whenFingerprintDoesNotExistInDocumentRecord() {
        // Given
        DocumentFingerprint fingerprint = new DocumentFingerprint(
                "8888888888888888888888888888888888888888888888888888888888888888");
        RunId runId = new RunId("test-run-7");
        Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);

        ProcessingAttempt attempt = ProcessingAttempt.withoutAiFields(
                fingerprint,
                runId,
                1,
                now,
                now.plusSeconds(10),
                ProcessingStatus.FAILED_RETRYABLE,
                "IOException",
                "File not found",
                true
        );

        // When / Then
        assertThatThrownBy(() -> repository.save(attempt))
                .isInstanceOf(DocumentPersistenceException.class);
    }

    // -------------------------------------------------------------------------
    // Helpers
    // -------------------------------------------------------------------------

    private void insertDocumentRecord(DocumentFingerprint fingerprint) {
        String sql = """
                INSERT INTO document_record (
                    fingerprint,
                    last_known_source_locator,
                    last_known_source_file_name,
                    overall_status,
                    content_error_count,
                    transient_error_count,
                    created_at,
                    updated_at
                ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
                """;

        try (Connection connection = DriverManager.getConnection(jdbcUrl);
             var statement = connection.prepareStatement(sql)) {

            statement.setString(1, fingerprint.sha256Hex());
            statement.setString(2, "/test/path/document.pdf");
            statement.setString(3, "document.pdf");
            statement.setString(4, ProcessingStatus.PROCESSING.name());
            statement.setInt(5, 0);
            statement.setInt(6, 0);
            Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);
            statement.setString(7, now.toString());
            statement.setString(8, now.toString());

            statement.executeUpdate();
        } catch (SQLException e) {
            throw new RuntimeException("Failed to insert document record for testing", e);
        }
    }

    // -------------------------------------------------------------------------
    // deleteAllByFingerprint
    // -------------------------------------------------------------------------

    @Test
    void deleteAllByFingerprint_removesAllAttemptsForFingerprint() {
        DocumentFingerprint fingerprint = new DocumentFingerprint(
                "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc");
        RunId runId = new RunId("test-run-delete");
        Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);

        insertDocumentRecord(fingerprint);

        // Save two attempts
        for (int i = 1; i <= 2; i++) {
            repository.save(ProcessingAttempt.withoutAiFields(
                    fingerprint, runId, i, now, now.plusSeconds(i),
                    ProcessingStatus.FAILED_RETRYABLE, "SomeError", "message", true));
        }
        assertThat(repository.findAllByFingerprint(fingerprint)).hasSize(2);

        repository.deleteAllByFingerprint(fingerprint);

        assertThat(repository.findAllByFingerprint(fingerprint)).isEmpty();
    }

    @Test
    void deleteAllByFingerprint_isIdempotentWhenNoAttemptsExist() {
        DocumentFingerprint fingerprint = new DocumentFingerprint(
                "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd");

        // Must not throw even if no attempts exist for this fingerprint
        repository.deleteAllByFingerprint(fingerprint);

        assertThat(repository.findAllByFingerprint(fingerprint)).isEmpty();
    }

    @Test
    void deleteAllByFingerprint_doesNotAffectOtherFingerprints() {
        DocumentFingerprint fp1 = new DocumentFingerprint(
                "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee");
        DocumentFingerprint fp2 = new DocumentFingerprint(
                "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff");
        RunId runId = new RunId("run-isolation");
        Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);

        insertDocumentRecord(fp1);
        insertDocumentRecord(fp2);

        repository.save(ProcessingAttempt.withoutAiFields(
                fp1, runId, 1, now, now.plusSeconds(1),
                ProcessingStatus.FAILED_RETRYABLE, "Err", "msg", true));
        repository.save(ProcessingAttempt.withoutAiFields(
                fp2, runId, 1, now, now.plusSeconds(1),
                ProcessingStatus.FAILED_RETRYABLE, "Err", "msg", true));

        repository.deleteAllByFingerprint(fp1);

        assertThat(repository.findAllByFingerprint(fp1)).isEmpty();
        assertThat(repository.findAllByFingerprint(fp2)).hasSize(1);
    }
}