package org.raddatz.familienarchiv;

import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.config.FlywayConfig;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentRepository;
import org.raddatz.familienarchiv.document.DocumentStatus;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.context.annotation.Import;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

import java.util.UUID;

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

/**
 * Integration tests that verify DB-level constraints introduced in the OCR pipeline migrations
 * are actually enforced by PostgreSQL. These tests exercise constraints that cannot be verified
 * by unit tests alone.
 */
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import({PostgresContainerConfig.class, FlywayConfig.class})
class MigrationIntegrationTest {

    @Autowired JdbcTemplate jdbc;
    @Autowired DocumentRepository documentRepository;
    @Autowired EntityManager em;

    // ─── V23: chk_annotation_polygon_quad CHECK constraint ───────────────────

    @Test
    void v23_polygonCheckConstraint_rejectsNonQuadrilateral() {
        UUID docId = createDocument();

        // A 3-point polygon violates chk_annotation_polygon_quad (must be exactly 4 points or NULL)
        assertThatThrownBy(() ->
                jdbc.update(
                        """
                        INSERT INTO document_annotations
                            (id, document_id, page_number, x, y, width, height, color, polygon)
                        VALUES (gen_random_uuid(), ?, 1, 0.1, 0.1, 0.5, 0.1, '#00C7B1',
                                '[[0.1,0.1],[0.9,0.1],[0.9,0.2]]'::jsonb)
                        """,
                        docId)
        ).isInstanceOf(DataIntegrityViolationException.class);
    }

    @Test
    void v23_polygonCheckConstraint_allowsNullPolygon() {
        UUID docId = createDocument();

        int rows = jdbc.update(
                """
                INSERT INTO document_annotations
                    (id, document_id, page_number, x, y, width, height, color, polygon)
                VALUES (gen_random_uuid(), ?, 1, 0.1, 0.1, 0.5, 0.1, '#00C7B1', NULL)
                """,
                docId);

        assertThat(rows).isEqualTo(1);
    }

    @Test
    void v23_polygonCheckConstraint_allowsQuadrilateral() {
        UUID docId = createDocument();

        int rows = jdbc.update(
                """
                INSERT INTO document_annotations
                    (id, document_id, page_number, x, y, width, height, color, polygon)
                VALUES (gen_random_uuid(), ?, 1, 0.1, 0.1, 0.5, 0.1, '#00C7B1',
                        '[[0.1,0.1],[0.9,0.1],[0.9,0.2],[0.1,0.2]]'::jsonb)
                """,
                docId);

        assertThat(rows).isEqualTo(1);
    }

    // ─── V30: idx_ocr_training_runs_one_running partial unique index ──────────

    @Test
    @Transactional(propagation = Propagation.NOT_SUPPORTED)
    void v30_partialUniqueIndex_preventsTwoRunningTrainingRuns() {
        jdbc.update("""
                INSERT INTO ocr_training_runs (id, status, block_count, document_count, model_name)
                VALUES (gen_random_uuid(), 'RUNNING', 10, 2, 'kurrent_v1')
                """);

        // A second RUNNING row violates the partial unique index
        assertThatThrownBy(() ->
                jdbc.update("""
                        INSERT INTO ocr_training_runs (id, status, block_count, document_count, model_name)
                        VALUES (gen_random_uuid(), 'RUNNING', 5, 1, 'kurrent_v1')
                        """)
        ).isInstanceOf(DataIntegrityViolationException.class);

        // Clean up — runs outside the DataJpaTest transaction, so must be explicit
        jdbc.update("DELETE FROM ocr_training_runs WHERE status = 'RUNNING'");
    }

    @Test
    void v30_partialUniqueIndex_allowsMultipleDoneRuns() {
        int rows1 = jdbc.update("""
                INSERT INTO ocr_training_runs (id, status, block_count, document_count, model_name)
                VALUES (gen_random_uuid(), 'DONE', 10, 2, 'kurrent_v1')
                """);
        int rows2 = jdbc.update("""
                INSERT INTO ocr_training_runs (id, status, block_count, document_count, model_name)
                VALUES (gen_random_uuid(), 'DONE', 15, 3, 'kurrent_v2')
                """);

        assertThat(rows1).isEqualTo(1);
        assertThat(rows2).isEqualTo(1);
    }

    // ─── V33: chk_annotation_bounds CHECK constraint ─────────────────────────

    @Test
    void v33_boundsCheckConstraint_rejectsXAboveOne() {
        UUID docId = createDocument();

        assertThatThrownBy(() ->
                jdbc.update(
                        """
                        INSERT INTO document_annotations
                            (id, document_id, page_number, x, y, width, height, color)
                        VALUES (gen_random_uuid(), ?, 1, 1.5, 0.1, 0.3, 0.1, '#ff0000')
                        """,
                        docId)
        ).isInstanceOf(DataIntegrityViolationException.class);
    }

    @Test
    void v33_boundsCheckConstraint_rejectsHeightBelowMinimum() {
        UUID docId = createDocument();

        assertThatThrownBy(() ->
                jdbc.update(
                        """
                        INSERT INTO document_annotations
                            (id, document_id, page_number, x, y, width, height, color)
                        VALUES (gen_random_uuid(), ?, 1, 0.1, 0.1, 0.3, 0.005, '#ff0000')
                        """,
                        docId)
        ).isInstanceOf(DataIntegrityViolationException.class);
    }

    @Test
    void v33_boundsCheckConstraint_acceptsValidAnnotation() {
        UUID docId = createDocument();

        int rows = jdbc.update(
                """
                INSERT INTO document_annotations
                    (id, document_id, page_number, x, y, width, height, color)
                VALUES (gen_random_uuid(), ?, 1, 0.1, 0.1, 0.3, 0.1, '#ff0000')
                """,
                docId);

        assertThat(rows).isEqualTo(1);
    }

    // ─── V39: tag hierarchy — parent_id FK + self-reference check + color ──────

    @Test
    void v39_parentId_allowsNull() {
        UUID tagId = createTag("TagWithoutParent");

        Integer count = jdbc.queryForObject(
                "SELECT COUNT(*) FROM tag WHERE id = ? AND parent_id IS NULL", Integer.class, tagId);
        assertThat(count).isEqualTo(1);
    }

    @Test
    void v39_selfReferenceCheck_rejectsSelfAsParent() {
        UUID tagId = createTag("SelfRef");

        assertThatThrownBy(() ->
                jdbc.update("UPDATE tag SET parent_id = id WHERE id = ?", tagId)
        ).isInstanceOf(DataIntegrityViolationException.class);
    }

    @Test
    void v39_parentId_acceptsValidParent() {
        UUID parent = createTag("Parent");
        UUID child = createTag("Child");

        int rows = jdbc.update("UPDATE tag SET parent_id = ? WHERE id = ?", parent, child);
        assertThat(rows).isEqualTo(1);
    }

    @Test
    void v39_color_allowsNull() {
        UUID tagId = createTag("ColorlessTag");

        Integer count = jdbc.queryForObject(
                "SELECT COUNT(*) FROM tag WHERE id = ? AND color IS NULL", Integer.class, tagId);
        assertThat(count).isEqualTo(1);
    }

    @Test
    void v39_color_storesTokenName() {
        UUID tagId = createTag("ColoredTag");

        int rows = jdbc.update("UPDATE tag SET color = 'sage' WHERE id = ?", tagId);
        String stored = jdbc.queryForObject("SELECT color FROM tag WHERE id = ?", String.class, tagId);

        assertThat(rows).isEqualTo(1);
        assertThat(stored).isEqualTo("sage");
    }

    // ─── V42: idx_training_runs_queued_per_person partial unique index ────────

    @Test
    @Transactional(propagation = Propagation.NOT_SUPPORTED)
    void v42_partialUniqueIndex_preventsTwoQueuedRunsForSamePerson() {
        UUID personId = createPerson("V42Test", "SamePerson");
        jdbc.update("""
                INSERT INTO ocr_training_runs (id, status, block_count, document_count, model_name, person_id)
                VALUES (gen_random_uuid(), 'QUEUED', 10, 2, 'sender_x', ?)
                """, personId);

        assertThatThrownBy(() ->
                jdbc.update("""
                        INSERT INTO ocr_training_runs (id, status, block_count, document_count, model_name, person_id)
                        VALUES (gen_random_uuid(), 'QUEUED', 5, 1, 'sender_x', ?)
                        """, personId)
        ).isInstanceOf(DataIntegrityViolationException.class);

        jdbc.update("DELETE FROM ocr_training_runs WHERE person_id = ?", personId);
        jdbc.update("DELETE FROM persons WHERE id = ?", personId);
    }

    @Test
    void v42_partialUniqueIndex_allowsQueuedRunsForDifferentPersons() {
        UUID person1 = createPerson("V42Test", "PersonA");
        UUID person2 = createPerson("V42Test", "PersonB");

        int rows1 = jdbc.update("""
                INSERT INTO ocr_training_runs (id, status, block_count, document_count, model_name, person_id)
                VALUES (gen_random_uuid(), 'QUEUED', 10, 2, 'sender_a', ?)
                """, person1);
        int rows2 = jdbc.update("""
                INSERT INTO ocr_training_runs (id, status, block_count, document_count, model_name, person_id)
                VALUES (gen_random_uuid(), 'QUEUED', 5, 1, 'sender_b', ?)
                """, person2);

        assertThat(rows1).isEqualTo(1);
        assertThat(rows2).isEqualTo(1);
    }

    @Test
    void v42_partialUniqueIndex_allowsMultipleDoneRunsForSamePerson() {
        UUID personId = createPerson("V42Test", "DonePerson");

        int rows1 = jdbc.update("""
                INSERT INTO ocr_training_runs (id, status, block_count, document_count, model_name, person_id)
                VALUES (gen_random_uuid(), 'DONE', 10, 2, 'sender_x', ?)
                """, personId);
        int rows2 = jdbc.update("""
                INSERT INTO ocr_training_runs (id, status, block_count, document_count, model_name, person_id)
                VALUES (gen_random_uuid(), 'DONE', 15, 3, 'sender_x', ?)
                """, personId);

        assertThat(rows1).isEqualTo(1);
        assertThat(rows2).isEqualTo(1);
    }

    // ─── V44: email NOT NULL constraint ──────────────────────────────────────

    @Test
    void v44_emailNotNullConstraint_rejectsInsertWithNullEmail() {
        assertThatThrownBy(() ->
                jdbc.update("""
                        INSERT INTO app_users (id, email, password, enabled, notify_on_reply, notify_on_mention)
                        VALUES (gen_random_uuid(), NULL, 'hash', true, false, false)
                        """)
        ).isInstanceOf(DataIntegrityViolationException.class);
    }

    @Test
    void v44_emailUniqueConstraint_rejectsDuplicateEmail() {
        String email = "unique-test-" + UUID.randomUUID() + "@example.com";
        jdbc.update("""
                INSERT INTO app_users (id, email, password, enabled, notify_on_reply, notify_on_mention)
                VALUES (gen_random_uuid(), ?, 'hash', true, false, false)
                """, email);

        assertThatThrownBy(() ->
                jdbc.update("""
                        INSERT INTO app_users (id, email, password, enabled, notify_on_reply, notify_on_mention)
                        VALUES (gen_random_uuid(), ?, 'hash', true, false, false)
                        """, email)
        ).isInstanceOf(DataIntegrityViolationException.class);
    }

    // ─── V53: add thumbnail_aspect + page_count columns to documents ─────────

    @Test
    void v53_thumbnailAspectColumn_existsAndIsNullable() {
        UUID docId = createDocument();

        // Column must exist and accept NULL (freshly-created doc has no thumbnail yet)
        String aspect = jdbc.queryForObject(
                "SELECT thumbnail_aspect FROM documents WHERE id = ?", String.class, docId);
        assertThat(aspect).isNull();
    }

    @Test
    void v53_pageCountColumn_existsAndIsNullable() {
        UUID docId = createDocument();

        Integer pageCount = jdbc.queryForObject(
                "SELECT page_count FROM documents WHERE id = ?", Integer.class, docId);
        assertThat(pageCount).isNull();
    }

    @Test
    void v53_thumbnailAspectColumn_acceptsPortraitAndLandscape() {
        UUID docId = createDocument();

        int portraitRows = jdbc.update(
                "UPDATE documents SET thumbnail_aspect = 'PORTRAIT' WHERE id = ?", docId);
        assertThat(portraitRows).isEqualTo(1);

        int landscapeRows = jdbc.update(
                "UPDATE documents SET thumbnail_aspect = 'LANDSCAPE' WHERE id = ?", docId);
        assertThat(landscapeRows).isEqualTo(1);
    }

    @Test
    void v53_pageCountColumn_storesInteger() {
        UUID docId = createDocument();

        jdbc.update("UPDATE documents SET page_count = 4 WHERE id = ?", docId);

        Integer stored = jdbc.queryForObject(
                "SELECT page_count FROM documents WHERE id = ?", Integer.class, docId);
        assertThat(stored).isEqualTo(4);
    }

    // ─── V51: backfill annotation_id on block comments and notifications ─────

    @Test
    void v51_backfillsAnnotationIdOnBlockCommentsFromTheirBlocks() {
        UUID docId = createDocument();
        UUID annotationId = insertAnnotation(docId);
        UUID blockId = insertBlock(docId, annotationId);
        UUID commentId = insertBlockCommentWithNullAnnotationId(docId, blockId);

        jdbc.update(V51_BACKFILL_COMMENTS_SQL);

        UUID stored = jdbc.queryForObject(
                "SELECT annotation_id FROM document_comments WHERE id = ?",
                UUID.class, commentId);
        assertThat(stored).isEqualTo(annotationId);
    }

    @Test
    void v51_backfillsAnnotationIdOnNotificationsFromTheirReferencedComment() {
        UUID docId = createDocument();
        UUID userId = insertUser("recipient-" + UUID.randomUUID() + "@example.com");
        UUID annotationId = insertAnnotation(docId);
        UUID blockId = insertBlock(docId, annotationId);
        UUID commentId = insertBlockCommentWithAnnotationId(docId, blockId, annotationId);
        UUID notificationId = insertNotificationWithNullAnnotationId(docId, commentId, userId);

        jdbc.update(V51_BACKFILL_NOTIFICATIONS_SQL);

        UUID stored = jdbc.queryForObject(
                "SELECT annotation_id FROM notifications WHERE id = ?",
                UUID.class, notificationId);
        assertThat(stored).isEqualTo(annotationId);
    }

    private static final String V51_BACKFILL_COMMENTS_SQL = """
            UPDATE document_comments dc
            SET annotation_id = tb.annotation_id
            FROM transcription_blocks tb
            WHERE dc.block_id = tb.id
              AND dc.annotation_id IS NULL
            """;

    private static final String V51_BACKFILL_NOTIFICATIONS_SQL = """
            UPDATE notifications n
            SET annotation_id = dc.annotation_id
            FROM document_comments dc
            WHERE n.reference_id = dc.id
              AND n.annotation_id IS NULL
              AND dc.annotation_id IS NOT NULL
            """;

    // ─── V62: indexes on FK columns ──────────────────────────────────────────

    @Test
    void v62_idx_documents_sender_id_exists() {
        Integer count = jdbc.queryForObject(
                "SELECT COUNT(*) FROM pg_catalog.pg_indexes WHERE tablename = 'documents' AND indexname = 'idx_documents_sender_id'",
                Integer.class);
        assertThat(count).isEqualTo(1);
    }

    @Test
    void v62_idx_comments_author_id_exists() {
        Integer count = jdbc.queryForObject(
                "SELECT COUNT(*) FROM pg_catalog.pg_indexes WHERE tablename = 'document_comments' AND indexname = 'idx_comments_author_id'",
                Integer.class);
        assertThat(count).isEqualTo(1);
    }

    // ─── V63+V64: group_permissions dedup + primary key ──────────────────────

    @Test
    void v64_pk_group_permissions_exists() {
        Integer count = jdbc.queryForObject(
                """
                SELECT COUNT(*) FROM pg_catalog.pg_constraint c
                JOIN pg_catalog.pg_class t ON c.conrelid = t.oid
                WHERE t.relname = 'group_permissions'
                  AND c.conname = 'pk_group_permissions'
                  AND c.contype = 'p'
                """,
                Integer.class);
        assertThat(count).isEqualTo(1);
    }

    @Test
    void v64_permission_column_isNotNullable() {
        Integer count = jdbc.queryForObject(
                """
                SELECT COUNT(*) FROM information_schema.columns
                WHERE table_schema = 'public'
                  AND table_name = 'group_permissions'
                  AND column_name = 'permission'
                  AND is_nullable = 'NO'
                """,
                Integer.class);
        assertThat(count).isEqualTo(1);
    }

    @Test
    @Transactional(propagation = Propagation.NOT_SUPPORTED)
    void v64_rejectsDuplicateGroupPermission() {
        UUID groupId = createUserGroup("DuplicateTestGroup-" + UUID.randomUUID());
        try {
            jdbc.update("INSERT INTO group_permissions (group_id, permission) VALUES (?, 'READ_ALL')", groupId);

            assertThatThrownBy(() ->
                    jdbc.update("INSERT INTO group_permissions (group_id, permission) VALUES (?, 'READ_ALL')", groupId)
            ).isInstanceOf(DataIntegrityViolationException.class);
        } finally {
            jdbc.update("DELETE FROM group_permissions WHERE group_id = ?", groupId);
            jdbc.update("DELETE FROM user_groups WHERE id = ?", groupId);
        }
    }

    // ─── V65: tbmp UNIQUE promoted to PRIMARY KEY ─────────────────────────────

    @Test
    void v65_pk_tbmp_exists() {
        Integer count = jdbc.queryForObject(
                """
                SELECT COUNT(*) FROM pg_catalog.pg_constraint c
                JOIN pg_catalog.pg_class t ON c.conrelid = t.oid
                WHERE t.relname = 'transcription_block_mentioned_persons'
                  AND c.conname = 'pk_tbmp'
                  AND c.contype = 'p'
                """,
                Integer.class);
        assertThat(count).isEqualTo(1);
    }

    // ─── V69: import/precision/attribution/identity schema foundation ────────

    @Test
    void v69_metaDatePrecisionColumn_isNotNull() {
        Integer count = jdbc.queryForObject(
                """
                SELECT COUNT(*) FROM information_schema.columns
                WHERE table_schema = 'public'
                  AND table_name = 'documents'
                  AND column_name = 'meta_date_precision'
                  AND is_nullable = 'NO'
                """,
                Integer.class);
        assertThat(count).isEqualTo(1);
    }

    @Test
    void v69_backfillSql_setsDatedRowsToDayPrecision() {
        // Re-run the migration's backfill UPDATE on a freshly dated row to prove the rule.
        UUID docId = createDocumentWithDate("1943-05-12");

        jdbc.update(V69_BACKFILL_PRECISION_SQL);

        String precision = jdbc.queryForObject(
                "SELECT meta_date_precision FROM documents WHERE id = ?", String.class, docId);
        assertThat(precision).isEqualTo("DAY");
    }

    @Test
    void v69_backfillSql_setsUndatedRowsToUnknownPrecision() {
        UUID docId = createDocument(); // no meta_date

        jdbc.update(V69_BACKFILL_PRECISION_SQL);

        String precision = jdbc.queryForObject(
                "SELECT meta_date_precision FROM documents WHERE id = ?", String.class, docId);
        assertThat(precision).isEqualTo("UNKNOWN");
    }

    // Mirrors the backfill UPDATE shipped in V69; idempotent for verification.
    private static final String V69_BACKFILL_PRECISION_SQL = """
            UPDATE documents
            SET meta_date_precision = CASE WHEN meta_date IS NOT NULL THEN 'DAY' ELSE 'UNKNOWN' END
            """;

    @Test
    void v69_precisionCheck_rejectsValueOutsideEnum() {
        UUID docId = createDocument();

        assertThatThrownBy(() ->
                jdbc.update("UPDATE documents SET meta_date_precision = 'BOGUS' WHERE id = ?", docId)
        ).isInstanceOf(DataIntegrityViolationException.class);
    }

    @Test
    void v69_metaDateEndCheck_rejectsNonNullEndWhenPrecisionNotRange() {
        UUID docId = createDocumentWithDate("1943-05-12"); // precision DAY

        assertThatThrownBy(() ->
                jdbc.update("UPDATE documents SET meta_date_end = '1943-06-01' WHERE id = ?", docId)
        ).isInstanceOf(DataIntegrityViolationException.class);
    }

    @Test
    void v69_metaDateEndCheck_allowsNonNullEndWhenPrecisionRange() {
        UUID docId = createDocumentWithDate("1943-05-12");

        int rows = jdbc.update(
                "UPDATE documents SET meta_date_precision = 'RANGE', meta_date_end = '1943-06-01' WHERE id = ?",
                docId);
        assertThat(rows).isEqualTo(1);
    }

    @Test
    void v69_metaDateEndCheck_allowsRangeWithNullEnd() {
        // Loose semantics: the normalizer may emit an open-ended RANGE (start only).
        UUID docId = createDocumentWithDate("1943-05-12");

        int rows = jdbc.update(
                "UPDATE documents SET meta_date_precision = 'RANGE' WHERE id = ?", docId);
        assertThat(rows).isEqualTo(1);
    }

    @Test
    void v69_metaDateEndCheck_allowsRangeWithBothEndpointsNull() {
        // Fully-open RANGE: neither start (meta_date) nor end (meta_date_end) is set.
        // Both CHECKs hold (end IS NULL passes chk_meta_date_end_only_for_range; both-null
        // passes chk_meta_date_end_after_start), so the row survives. This locks the actual
        // DB behavior so a future tightening to a biconditional rule is a deliberate change.
        UUID docId = createDocument(); // null meta_date

        int rows = jdbc.update(
                "UPDATE documents SET meta_date_precision = 'RANGE' WHERE id = ?", docId);
        assertThat(rows).isEqualTo(1);

        Object metaDate = jdbc.queryForObject("SELECT meta_date FROM documents WHERE id = ?", Object.class, docId);
        Object metaDateEnd = jdbc.queryForObject(
                "SELECT meta_date_end FROM documents WHERE id = ?", Object.class, docId);
        assertThat(metaDate).isNull();
        assertThat(metaDateEnd).isNull();
    }

    @Test
    void v69_rangeOrderCheck_rejectsEndBeforeStart() {
        UUID docId = createDocumentWithDate("1943-05-12");

        assertThatThrownBy(() ->
                jdbc.update(
                        "UPDATE documents SET meta_date_precision = 'RANGE', meta_date_end = '1943-01-01' WHERE id = ?",
                        docId)
        ).isInstanceOf(DataIntegrityViolationException.class);
    }

    @Test
    void v69_metaDateRawCheck_rejectsOverlongText() {
        UUID docId = createDocument();
        String tooLong = "x".repeat(10001);

        assertThatThrownBy(() ->
                jdbc.update("UPDATE documents SET meta_date_raw = ? WHERE id = ?", tooLong, docId)
        ).isInstanceOf(DataIntegrityViolationException.class);
    }

    @Test
    void v69_senderTextAndReceiverText_storeRawAttribution() {
        UUID docId = createDocument();

        int rows = jdbc.update(
                "UPDATE documents SET sender_text = 'Oma Anna', receiver_text = 'Tante Grete' WHERE id = ?",
                docId);
        assertThat(rows).isEqualTo(1);
    }

    @Test
    @Transactional(propagation = Propagation.NOT_SUPPORTED)
    void v69_personsSourceRef_uniqueIndexRejectsDuplicate() {
        jdbc.update(
                "INSERT INTO persons (id, last_name, source_ref) VALUES (gen_random_uuid(), 'A', 'person:dup')");
        try {
            assertThatThrownBy(() ->
                    jdbc.update(
                            "INSERT INTO persons (id, last_name, source_ref) VALUES (gen_random_uuid(), 'B', 'person:dup')")
            ).isInstanceOf(DataIntegrityViolationException.class);
        } finally {
            jdbc.update("DELETE FROM persons WHERE source_ref = 'person:dup'");
        }
    }

    @Test
    @Transactional(propagation = Propagation.NOT_SUPPORTED)
    void v69_personsSourceRef_allowsMultipleNulls() {
        UUID a = createPerson("Null", "RefA");
        UUID b = createPerson("Null", "RefB");
        try {
            String refA = jdbc.queryForObject("SELECT source_ref FROM persons WHERE id = ?", String.class, a);
            String refB = jdbc.queryForObject("SELECT source_ref FROM persons WHERE id = ?", String.class, b);
            assertThat(refA).isNull();
            assertThat(refB).isNull();
        } finally {
            jdbc.update("DELETE FROM persons WHERE id IN (?, ?)", a, b);
        }
    }

    @Test
    void v69_personsProvisional_defaultsToFalse() {
        UUID id = createPerson("Provisional", "Default");

        Boolean provisional = jdbc.queryForObject(
                "SELECT provisional FROM persons WHERE id = ?", Boolean.class, id);
        assertThat(provisional).isFalse();
    }

    @Test
    @Transactional(propagation = Propagation.NOT_SUPPORTED)
    void v69_tagSourceRef_uniqueIndexRejectsDuplicate() {
        jdbc.update("INSERT INTO tag (id, name, source_ref) VALUES (gen_random_uuid(), 'TagDupA', 'tag:dup')");
        try {
            assertThatThrownBy(() ->
                    jdbc.update("INSERT INTO tag (id, name, source_ref) VALUES (gen_random_uuid(), 'TagDupB', 'tag:dup')")
            ).isInstanceOf(DataIntegrityViolationException.class);
        } finally {
            jdbc.update("DELETE FROM tag WHERE source_ref = 'tag:dup'");
        }
    }

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

    private UUID createPerson(String firstName, String lastName) {
        UUID id = UUID.randomUUID();
        jdbc.update("INSERT INTO persons (id, first_name, last_name) VALUES (?, ?, ?)", id, firstName, lastName);
        return id;
    }

    private UUID createTag(String name) {
        UUID id = UUID.randomUUID();
        jdbc.update("INSERT INTO tag (id, name) VALUES (?, ?)", id, name);
        return id;
    }

    private UUID createDocument() {
        Document doc = documentRepository.save(Document.builder()
                .title("Testdokument")
                .originalFilename("test.pdf")
                .status(DocumentStatus.UPLOADED)
                .build());
        // Flush so the row is visible to subsequent JdbcTemplate queries within the same transaction
        em.flush();
        return doc.getId();
    }

    private UUID createDocumentWithDate(String isoDate) {
        UUID id = createDocument();
        jdbc.update("UPDATE documents SET meta_date = ?::date WHERE id = ?", isoDate, id);
        return id;
    }

    private UUID insertAnnotation(UUID docId) {
        UUID id = UUID.randomUUID();
        jdbc.update("""
                INSERT INTO document_annotations
                    (id, document_id, page_number, x, y, width, height, color)
                VALUES (?, ?, 1, 0.1, 0.1, 0.3, 0.1, '#00C7B1')
                """, id, docId);
        return id;
    }

    private UUID insertBlock(UUID docId, UUID annotationId) {
        UUID id = UUID.randomUUID();
        jdbc.update("""
                INSERT INTO transcription_blocks
                    (id, annotation_id, document_id, text, sort_order)
                VALUES (?, ?, ?, '', 0)
                """, id, annotationId, docId);
        return id;
    }

    private UUID insertUser(String email) {
        UUID id = UUID.randomUUID();
        jdbc.update("""
                INSERT INTO app_users (id, email, password, enabled, notify_on_reply, notify_on_mention)
                VALUES (?, ?, 'hash', true, false, false)
                """, id, email);
        return id;
    }

    private UUID insertBlockCommentWithNullAnnotationId(UUID docId, UUID blockId) {
        UUID id = UUID.randomUUID();
        jdbc.update("""
                INSERT INTO document_comments
                    (id, document_id, block_id, annotation_id, author_name, content)
                VALUES (?, ?, ?, NULL, 'Tester', 'Hi')
                """, id, docId, blockId);
        return id;
    }

    private UUID insertBlockCommentWithAnnotationId(UUID docId, UUID blockId, UUID annotationId) {
        UUID id = UUID.randomUUID();
        jdbc.update("""
                INSERT INTO document_comments
                    (id, document_id, block_id, annotation_id, author_name, content)
                VALUES (?, ?, ?, ?, 'Tester', 'Hi')
                """, id, docId, blockId, annotationId);
        return id;
    }

    private UUID insertNotificationWithNullAnnotationId(UUID docId, UUID commentId, UUID recipientId) {
        UUID id = UUID.randomUUID();
        jdbc.update("""
                INSERT INTO notifications
                    (id, recipient_id, type, document_id, reference_id, annotation_id, read, actor_name)
                VALUES (?, ?, 'MENTION', ?, ?, NULL, false, 'Tester')
                """, id, recipientId, docId, commentId);
        return id;
    }

    private UUID createUserGroup(String name) {
        UUID id = UUID.randomUUID();
        jdbc.update("INSERT INTO user_groups (id, name) VALUES (?, ?)", id, name);
        return id;
    }
}
