/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.apache.paimon.operation.commit;

import org.apache.paimon.Snapshot;
import org.apache.paimon.data.BinaryRow;
import org.apache.paimon.index.DeletionVectorMeta;
import org.apache.paimon.index.GlobalIndexMeta;
import org.apache.paimon.index.IndexFileMeta;
import org.apache.paimon.manifest.FileEntry;
import org.apache.paimon.manifest.FileKind;
import org.apache.paimon.manifest.IndexManifestEntry;
import org.apache.paimon.manifest.SimpleFileEntry;
import org.apache.paimon.manifest.SimpleFileEntryWithDV;
import org.apache.paimon.table.BucketMode;
import org.apache.paimon.types.RowType;

import org.junit.jupiter.api.Test;

import javax.annotation.Nullable;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Optional;

import static org.apache.paimon.data.BinaryRow.EMPTY_ROW;
import static org.apache.paimon.deletionvectors.DeletionVectorsIndexFile.DELETION_VECTORS_INDEX;
import static org.apache.paimon.manifest.FileKind.ADD;
import static org.apache.paimon.manifest.FileKind.DELETE;
import static org.apache.paimon.operation.commit.ConflictDetection.buildBaseEntriesWithDV;
import static org.apache.paimon.operation.commit.ConflictDetection.buildDeltaEntriesWithDV;
import static org.assertj.core.api.Assertions.assertThat;

class ConflictDetectionTest {

    @Test
    public void testBuildBaseEntriesWithDV() {
        {
            // Scene 1
            List<SimpleFileEntry> baseEntries = new ArrayList<>();
            baseEntries.add(createFileEntry("f1", ADD));
            baseEntries.add(createFileEntry("f2", ADD));

            List<IndexManifestEntry> deltaIndexEntries = new ArrayList<>();
            deltaIndexEntries.add(createDvIndexEntry("dv1", ADD, Arrays.asList("f2")));

            assertThat(buildBaseEntriesWithDV(baseEntries, deltaIndexEntries))
                    .containsExactlyInAnyOrder(
                            createFileEntryWithDV("f1", ADD, null),
                            createFileEntryWithDV("f2", ADD, "dv1"));
        }

        {
            // Scene 2: skip delete dv
            List<SimpleFileEntry> baseEntries = new ArrayList<>();
            baseEntries.add(createFileEntry("f1", ADD));
            baseEntries.add(createFileEntry("f2", ADD));

            List<IndexManifestEntry> deltaIndexEntries = new ArrayList<>();
            deltaIndexEntries.add(createDvIndexEntry("dv1", DELETE, Arrays.asList("f2")));

            assertThat(buildBaseEntriesWithDV(baseEntries, deltaIndexEntries))
                    .containsExactlyInAnyOrder(
                            createFileEntryWithDV("f1", ADD, null),
                            createFileEntryWithDV("f2", ADD, null));
        }
    }

    @Test
    public void testBuildDeltaEntriesWithDV() {
        {
            // Scene 1: update f2's dv
            List<SimpleFileEntry> baseEntries = new ArrayList<>();
            baseEntries.add(createFileEntryWithDV("f1", ADD, "dv1"));
            baseEntries.add(createFileEntryWithDV("f2", ADD, null));

            List<SimpleFileEntry> deltaEntries = new ArrayList<>();
            deltaEntries.add(createFileEntry("f2", DELETE));
            deltaEntries.add(createFileEntry("f2_new", ADD));

            List<IndexManifestEntry> deltaIndexEntries = new ArrayList<>();
            deltaIndexEntries.add(createDvIndexEntry("dv2", ADD, Arrays.asList("f2_new")));

            assertThat(buildDeltaEntriesWithDV(baseEntries, deltaEntries, deltaIndexEntries))
                    .containsExactlyInAnyOrder(
                            createFileEntryWithDV("f2", DELETE, null),
                            createFileEntryWithDV("f2_new", ADD, "dv2"));
        }

        {
            // Scene 2: update f2 and merge f1's dv
            List<SimpleFileEntry> baseEntries = new ArrayList<>();
            baseEntries.add(createFileEntryWithDV("f1", ADD, "dv1"));
            baseEntries.add(createFileEntryWithDV("f2", ADD, null));

            List<SimpleFileEntry> deltaEntries = new ArrayList<>();
            deltaEntries.add(createFileEntry("f2", DELETE));
            deltaEntries.add(createFileEntry("f2_new", ADD));
            deltaEntries.add(createFileEntry("f3", ADD));

            List<IndexManifestEntry> deltaIndexEntries = new ArrayList<>();
            deltaIndexEntries.add(createDvIndexEntry("dv1", DELETE, Arrays.asList("f1")));
            deltaIndexEntries.add(createDvIndexEntry("dv2", ADD, Arrays.asList("f1", "f2_new")));

            assertThat(buildDeltaEntriesWithDV(baseEntries, deltaEntries, deltaIndexEntries))
                    .containsExactlyInAnyOrder(
                            createFileEntryWithDV("f1", DELETE, "dv1"),
                            createFileEntryWithDV("f1", ADD, "dv2"),
                            createFileEntryWithDV("f2", DELETE, null),
                            createFileEntryWithDV("f2_new", ADD, "dv2"),
                            createFileEntryWithDV("f3", ADD, null));
        }

        {
            // Scene 3: update f2 (with dv) and merge f1's dv
            List<SimpleFileEntry> baseEntries = new ArrayList<>();
            baseEntries.add(createFileEntryWithDV("f1", ADD, "dv1"));
            baseEntries.add(createFileEntryWithDV("f2", ADD, "dv2"));

            List<SimpleFileEntry> deltaEntries = new ArrayList<>();
            deltaEntries.add(createFileEntry("f2", DELETE));
            deltaEntries.add(createFileEntry("f2_new", ADD));
            deltaEntries.add(createFileEntry("f3", ADD));

            List<IndexManifestEntry> deltaIndexEntries = new ArrayList<>();
            deltaIndexEntries.add(createDvIndexEntry("dv1", DELETE, Arrays.asList("f1")));
            deltaIndexEntries.add(createDvIndexEntry("dv2", DELETE, Arrays.asList("f2")));
            deltaIndexEntries.add(createDvIndexEntry("dv3", ADD, Arrays.asList("f1", "f2_new")));

            assertThat(buildDeltaEntriesWithDV(baseEntries, deltaEntries, deltaIndexEntries))
                    .containsExactlyInAnyOrder(
                            createFileEntryWithDV("f1", DELETE, "dv1"),
                            createFileEntryWithDV("f1", ADD, "dv3"),
                            createFileEntryWithDV("f2", DELETE, "dv2"),
                            createFileEntryWithDV("f2_new", ADD, "dv3"),
                            createFileEntryWithDV("f3", ADD, null));
        }

        {
            // Scene 4: full compact
            List<SimpleFileEntry> baseEntries = new ArrayList<>();
            baseEntries.add(createFileEntryWithDV("f1", ADD, null));
            baseEntries.add(createFileEntryWithDV("f2", ADD, "dv1"));
            baseEntries.add(createFileEntryWithDV("f3", ADD, "dv1"));
            baseEntries.add(createFileEntryWithDV("f4", ADD, "dv2"));

            List<SimpleFileEntry> deltaEntries = new ArrayList<>();
            deltaEntries.add(createFileEntry("f1", DELETE));
            deltaEntries.add(createFileEntry("f2", DELETE));
            deltaEntries.add(createFileEntry("f3", DELETE));
            deltaEntries.add(createFileEntry("f4", DELETE));
            deltaEntries.add(createFileEntry("f5_compact", ADD));

            List<IndexManifestEntry> deltaIndexEntries = new ArrayList<>();
            deltaIndexEntries.add(createDvIndexEntry("dv1", DELETE, Arrays.asList("f2", "f3")));
            deltaIndexEntries.add(createDvIndexEntry("dv2", DELETE, Arrays.asList("f4")));

            assertThat(buildDeltaEntriesWithDV(baseEntries, deltaEntries, deltaIndexEntries))
                    .containsExactlyInAnyOrder(
                            createFileEntryWithDV("f1", DELETE, null),
                            createFileEntryWithDV("f2", DELETE, "dv1"),
                            createFileEntryWithDV("f3", DELETE, "dv1"),
                            createFileEntryWithDV("f4", DELETE, "dv2"),
                            createFileEntryWithDV("f5_compact", ADD, null));
        }

        {
            // Scene 5: merge into with update, delete and insert
            List<SimpleFileEntry> baseEntries = new ArrayList<>();
            baseEntries.add(createFileEntryWithDV("f1", ADD, null));
            baseEntries.add(createFileEntryWithDV("f2", ADD, null));
            baseEntries.add(createFileEntryWithDV("f3", ADD, "dv1"));
            baseEntries.add(createFileEntryWithDV("f4", ADD, "dv1"));
            baseEntries.add(createFileEntryWithDV("f5", ADD, "dv2"));

            List<SimpleFileEntry> deltaEntries = new ArrayList<>();
            deltaEntries.add(createFileEntry("f2", DELETE));
            deltaEntries.add(createFileEntry("f3", DELETE));
            deltaEntries.add(createFileEntry("f3_new", ADD));
            deltaEntries.add(createFileEntry("f7", ADD));

            List<IndexManifestEntry> deltaIndexEntries = new ArrayList<>();
            deltaIndexEntries.add(createDvIndexEntry("dv1", DELETE, Arrays.asList("f3", "f4")));
            deltaIndexEntries.add(createDvIndexEntry("dv2", DELETE, Arrays.asList("f5")));
            deltaIndexEntries.add(createDvIndexEntry("dv3", ADD, Arrays.asList("f1", "f4", "f5")));

            assertThat(buildDeltaEntriesWithDV(baseEntries, deltaEntries, deltaIndexEntries))
                    .containsExactlyInAnyOrder(
                            createFileEntryWithDV("f1", DELETE, null),
                            createFileEntryWithDV("f1", ADD, "dv3"),
                            createFileEntryWithDV("f2", DELETE, null),
                            createFileEntryWithDV("f3", DELETE, "dv1"),
                            createFileEntryWithDV("f3_new", ADD, null),
                            createFileEntryWithDV("f4", DELETE, "dv1"),
                            createFileEntryWithDV("f4", ADD, "dv3"),
                            createFileEntryWithDV("f5", DELETE, "dv2"),
                            createFileEntryWithDV("f5", ADD, "dv3"),
                            createFileEntryWithDV("f7", ADD, null));
        }
    }

    @Test
    public void testConflictDeletionWithDV() {
        {
            // Scene 1: base -------------> update2 (conflict)
            //           f1          ^         <f1, +dv2>
            //                       |
            //                  update1 (finished)
            //                    <f1, +dv1>
            List<SimpleFileEntry> update1Entries = new ArrayList<>();
            update1Entries.add(createFileEntryWithDV("f1", ADD, "dv1"));

            List<SimpleFileEntry> update2DeltaEntries = new ArrayList<>();

            List<IndexManifestEntry> update2DeltaIndexEntries = new ArrayList<>();
            update2DeltaIndexEntries.add(createDvIndexEntry("dv2", ADD, Arrays.asList("f1")));

            List<SimpleFileEntry> update2DeltaEntriesWithDV =
                    buildDeltaEntriesWithDV(
                            update1Entries, update2DeltaEntries, update2DeltaIndexEntries);
            assertThat(update2DeltaEntriesWithDV)
                    .containsExactlyInAnyOrder(
                            createFileEntryWithDV("f1", DELETE, null),
                            createFileEntryWithDV("f1", ADD, "dv2"));
            assertConflict(update1Entries, update2DeltaEntriesWithDV);
        }

        {
            // Scene 2: base -------------> update2 (conflict)
            //         <f1, dv0>     ^        <f1, +dv2>
            //                       |
            //                  update1 (finished)
            //                    <f1, +dv1>
            List<SimpleFileEntry> update1Entries = new ArrayList<>();
            update1Entries.add(createFileEntryWithDV("f1", ADD, "dv1"));

            List<SimpleFileEntry> update2DeltaEntries = new ArrayList<>();

            List<IndexManifestEntry> update2DeltaIndexEntries = new ArrayList<>();
            update2DeltaIndexEntries.add(createDvIndexEntry("dv0", DELETE, Arrays.asList("f1")));
            update2DeltaIndexEntries.add(createDvIndexEntry("dv2", ADD, Arrays.asList("f1")));

            List<SimpleFileEntry> update2DeltaEntriesWithDV =
                    buildDeltaEntriesWithDV(
                            update1Entries, update2DeltaEntries, update2DeltaIndexEntries);
            assertThat(update2DeltaEntriesWithDV)
                    .containsExactlyInAnyOrder(
                            createFileEntryWithDV("f1", DELETE, "dv0"),
                            createFileEntryWithDV("f1", ADD, "dv2"));
            assertConflict(update1Entries, update2DeltaEntriesWithDV);
        }

        {
            // Scene 3: base -------------> update2 (conflict)
            //         <f1, dv0>      ^     <-f1, -dv0>, <+f3, null>
            //                        |
            //                  update1 (finished)
            //                 <-f1, -dv0>, <+f2, dv1>
            List<SimpleFileEntry> update1Entries = new ArrayList<>();
            update1Entries.add(createFileEntryWithDV("f2", ADD, "dv1"));

            List<SimpleFileEntry> update2DeltaEntries = new ArrayList<>();
            update2DeltaEntries.add(createFileEntry("f1", DELETE));
            update2DeltaEntries.add(createFileEntry("f3", ADD));

            List<IndexManifestEntry> update2DeltaIndexEntries = new ArrayList<>();
            update2DeltaIndexEntries.add(createDvIndexEntry("dv0", DELETE, Arrays.asList("f1")));

            List<SimpleFileEntry> update2DeltaEntriesWithDV =
                    buildDeltaEntriesWithDV(
                            update1Entries, update2DeltaEntries, update2DeltaIndexEntries);
            assertThat(update2DeltaEntriesWithDV)
                    .containsExactlyInAnyOrder(
                            createFileEntryWithDV("f1", DELETE, "dv0"),
                            createFileEntryWithDV("f3", ADD, null));
            assertConflict(update1Entries, update2DeltaEntriesWithDV);
        }
    }

    private SimpleFileEntry createFileEntry(String fileName, FileKind kind) {
        return new SimpleFileEntry(
                kind,
                EMPTY_ROW,
                0,
                1,
                0,
                fileName,
                Collections.emptyList(),
                null,
                EMPTY_ROW,
                EMPTY_ROW,
                null,
                0L,
                null);
    }

    private SimpleFileEntryWithDV createFileEntryWithDV(
            String fileName, FileKind kind, @Nullable String dvFileName) {
        return new SimpleFileEntryWithDV(createFileEntry(fileName, kind), dvFileName);
    }

    private IndexManifestEntry createDvIndexEntry(
            String fileName, FileKind kind, List<String> fileNames) {
        LinkedHashMap<String, DeletionVectorMeta> dvRanges = new LinkedHashMap<>();
        for (String name : fileNames) {
            dvRanges.put(name, new DeletionVectorMeta(name, 1, 1, 1L));
        }
        return new IndexManifestEntry(
                kind,
                EMPTY_ROW,
                0,
                new IndexFileMeta(
                        DELETION_VECTORS_INDEX, fileName, 11, dvRanges.size(), dvRanges, null));
    }

    private IndexManifestEntry createGlobalIndexEntry(
            String fileName, FileKind kind, BinaryRow partition, long from, long to) {
        return createGlobalIndexEntry(fileName, kind, partition, 0, from, to);
    }

    private IndexManifestEntry createGlobalIndexEntry(
            String fileName, FileKind kind, BinaryRow partition, int bucket, long from, long to) {
        return new IndexManifestEntry(
                kind,
                partition,
                bucket,
                new IndexFileMeta(
                        "btree",
                        fileName,
                        11,
                        1,
                        new GlobalIndexMeta(from, to, 0, null, null),
                        null));
    }

    private void assertConflict(
            List<SimpleFileEntry> baseEntries, List<SimpleFileEntry> deltaEntries) {
        ArrayList<SimpleFileEntry> simpleFileEntryWithDVS = new ArrayList<>(baseEntries);
        simpleFileEntryWithDVS.addAll(deltaEntries);
        Collection<SimpleFileEntry> merged = FileEntry.mergeEntries(simpleFileEntryWithDVS);
        int deleteCount = 0;
        for (SimpleFileEntry simpleFileEntryWithDV : merged) {
            if (simpleFileEntryWithDV.kind().equals(FileKind.DELETE)) {
                deleteCount++;
            }
        }
        assert (deleteCount > 0);
    }

    @Test
    void testShouldBeOverwriteCommit() {
        ConflictDetection detection = createConflictDetection();

        List<SimpleFileEntry> addOnlyEntries = new ArrayList<>();
        addOnlyEntries.add(createFileEntry("f1", ADD));
        addOnlyEntries.add(createFileEntry("f2", ADD));
        assertThat(detection.shouldBeOverwriteCommit(addOnlyEntries, Collections.emptyList()))
                .isFalse();

        assertThat(
                        detection.shouldBeOverwriteCommit(
                                Collections.emptyList(), Collections.emptyList()))
                .isFalse();

        List<SimpleFileEntry> deleteEntries = new ArrayList<>();
        deleteEntries.add(createFileEntry("f1", DELETE));
        deleteEntries.add(createFileEntry("f2", ADD));
        assertThat(detection.shouldBeOverwriteCommit(deleteEntries, Collections.emptyList()))
                .isTrue();

        List<IndexManifestEntry> dvIndexFiles = new ArrayList<>();
        dvIndexFiles.add(createDvIndexEntry("dv1", ADD, Arrays.asList("f1")));
        assertThat(detection.shouldBeOverwriteCommit(Collections.emptyList(), dvIndexFiles))
                .isTrue();

        detection.setRowIdCheckFromSnapshot(1L);
        assertThat(detection.shouldBeOverwriteCommit(addOnlyEntries, Collections.emptyList()))
                .isFalse();
    }

    @Test
    void testChangedPartitionsIncludesGlobalIndexFiles() {
        BinaryRow partition = BinaryRow.singleColumn(1);

        assertThat(
                        ManifestEntryChanges.changedPartitions(
                                Collections.emptyList(),
                                Collections.singletonList(
                                        createGlobalIndexEntry("idx", ADD, partition, 0, 99))))
                .containsExactly(partition);
    }

    @Test
    void testCheckRowIdExistenceNoConflict() {
        ConflictDetection detection = createConflictDetection();

        List<SimpleFileEntry> baseEntries = new ArrayList<>();
        baseEntries.add(createFileEntryWithRowId("f1", ADD, 0L, 100L));

        List<SimpleFileEntry> deltaEntries = new ArrayList<>();
        deltaEntries.add(createFileEntryWithRowId("p1", ADD, 0L, 100L));

        assertThat(
                        detection.checkRowIdExistence(
                                baseEntries, deltaEntries, 100L, Snapshot.CommitKind.APPEND))
                .isEmpty();
    }

    @Test
    void testCheckRowIdExistenceBaseFileRemoved() {
        ConflictDetection detection = createConflictDetection();

        List<SimpleFileEntry> baseEntries = new ArrayList<>();

        List<SimpleFileEntry> deltaEntries = new ArrayList<>();
        deltaEntries.add(createFileEntryWithRowId("p1", ADD, 0L, 100L));

        Optional<RuntimeException> result =
                detection.checkRowIdExistence(
                        baseEntries, deltaEntries, 100L, Snapshot.CommitKind.APPEND);
        assertThat(result).isPresent();
        assertThat(result.get().getMessage()).contains("Row ID existence conflict");
    }

    @Test
    void testCheckRowIdExistenceBaseFileRewritten() {
        ConflictDetection detection = createConflictDetection();

        List<SimpleFileEntry> baseEntries = new ArrayList<>();
        baseEntries.add(createFileEntryWithRowId("f2", ADD, 0L, 200L));

        List<SimpleFileEntry> deltaEntries = new ArrayList<>();
        deltaEntries.add(createFileEntryWithRowId("p1", ADD, 0L, 100L));

        Optional<RuntimeException> result =
                detection.checkRowIdExistence(
                        baseEntries, deltaEntries, 200L, Snapshot.CommitKind.APPEND);
        assertThat(result).isPresent();
        assertThat(result.get().getMessage()).contains("Row ID existence conflict");
    }

    @Test
    void testCheckRowIdExistenceNormalFileRejectsAdjacentDataFiles() {
        ConflictDetection detection = createConflictDetection();

        List<SimpleFileEntry> baseEntries = new ArrayList<>();
        baseEntries.add(createFileEntryWithRowId("f1", ADD, 0L, 2L));
        baseEntries.add(createFileEntryWithRowId("f2", ADD, 2L, 2L));

        List<SimpleFileEntry> deltaEntries = new ArrayList<>();
        deltaEntries.add(createFileEntryWithRowId("p1", ADD, 0L, 4L));

        Optional<RuntimeException> result =
                detection.checkRowIdExistence(
                        baseEntries, deltaEntries, 4L, Snapshot.CommitKind.APPEND);
        assertThat(result).isPresent();
        assertThat(result.get().getMessage()).contains("Row ID existence conflict");
    }

    @Test
    void testCheckRowIdExistenceDedicatedFileCoveredByDataFiles() {
        ConflictDetection detection = createConflictDetection();

        List<SimpleFileEntry> baseEntries = new ArrayList<>();
        baseEntries.add(createFileEntryWithRowId("f1", ADD, 0L, 4L));

        List<SimpleFileEntry> deltaEntries = new ArrayList<>();
        deltaEntries.add(createFileEntryWithRowId("p1.blob", ADD, 0L, 2L));

        assertThat(
                        detection.checkRowIdExistence(
                                baseEntries, deltaEntries, 4L, Snapshot.CommitKind.APPEND))
                .isEmpty();
    }

    @Test
    void testCheckRowIdExistenceDedicatedFileRejectsAdjacentDataFiles() {
        ConflictDetection detection = createConflictDetection();

        List<SimpleFileEntry> baseEntries = new ArrayList<>();
        baseEntries.add(createFileEntryWithRowId("f1", ADD, 0L, 2L));
        baseEntries.add(createFileEntryWithRowId("f2", ADD, 2L, 2L));

        List<SimpleFileEntry> deltaEntries = new ArrayList<>();
        deltaEntries.add(createFileEntryWithRowId("p1.blob", ADD, 0L, 4L));

        Optional<RuntimeException> result =
                detection.checkRowIdExistence(
                        baseEntries, deltaEntries, 4L, Snapshot.CommitKind.APPEND);
        assertThat(result).isPresent();
        assertThat(result.get().getMessage()).contains("Row ID existence conflict");
    }

    @Test
    void testCheckRowIdExistenceDedicatedFileRejectsRangeNotCoveredByOneDataFile() {
        ConflictDetection detection = createConflictDetection();

        List<SimpleFileEntry> baseEntries = new ArrayList<>();
        baseEntries.add(createFileEntryWithRowId("f1", ADD, 0L, 2L));

        List<SimpleFileEntry> deltaEntries = new ArrayList<>();
        deltaEntries.add(createFileEntryWithRowId("p1.blob", ADD, 0L, 3L));

        Optional<RuntimeException> result =
                detection.checkRowIdExistence(
                        baseEntries, deltaEntries, 3L, Snapshot.CommitKind.APPEND);
        assertThat(result).isPresent();
        assertThat(result.get().getMessage()).contains("Row ID existence conflict");
    }

    @Test
    void testCheckRowIdExistenceDedicatedFileIgnoresBaseDedicatedFiles() {
        ConflictDetection detection = createConflictDetection();

        List<SimpleFileEntry> baseEntries = new ArrayList<>();
        baseEntries.add(createFileEntryWithRowId("old.blob", ADD, 0L, 2L));

        List<SimpleFileEntry> deltaEntries = new ArrayList<>();
        deltaEntries.add(createFileEntryWithRowId("p1.blob", ADD, 0L, 2L));

        Optional<RuntimeException> result =
                detection.checkRowIdExistence(
                        baseEntries, deltaEntries, 2L, Snapshot.CommitKind.APPEND);
        assertThat(result).isPresent();
        assertThat(result.get().getMessage()).contains("Row ID existence conflict");
    }

    @Test
    void testCheckRowIdExistenceSkipsNewlyAppendedFiles() {
        ConflictDetection detection = createConflictDetection();

        // nextRowId=100: files with firstRowId >= 100 are newly appended, not references
        List<SimpleFileEntry> baseEntries = new ArrayList<>();
        baseEntries.add(createFileEntryWithRowId("f1", ADD, 0L, 100L));

        List<SimpleFileEntry> deltaEntries = new ArrayList<>();
        // partial-column update referencing existing rows (firstRowId=0 < nextRowId=100)
        deltaEntries.add(createFileEntryWithRowId("p1", ADD, 0L, 100L));
        // newly appended file (firstRowId=100 >= nextRowId=100), should be skipped
        deltaEntries.add(createFileEntryWithRowId("new1", ADD, 100L, 50L));

        assertThat(
                        detection.checkRowIdExistence(
                                baseEntries, deltaEntries, 100L, Snapshot.CommitKind.APPEND))
                .isEmpty();
    }

    @Test
    void testCheckRowIdExistenceSkipsNonPreAssigned() {
        ConflictDetection detection = createConflictDetection();

        List<SimpleFileEntry> baseEntries = new ArrayList<>();

        List<SimpleFileEntry> deltaEntries = new ArrayList<>();
        deltaEntries.add(createFileEntry("f1", ADD));

        assertThat(
                        detection.checkRowIdExistence(
                                baseEntries, deltaEntries, 100L, Snapshot.CommitKind.APPEND))
                .isEmpty();
    }

    @Test
    void testCheckRowIdExistenceSkipsDeleteEntries() {
        ConflictDetection detection = createConflictDetection();

        List<SimpleFileEntry> baseEntries = new ArrayList<>();

        List<SimpleFileEntry> deltaEntries = new ArrayList<>();
        deltaEntries.add(createFileEntryWithRowId("f1", DELETE, 0L, 100L));

        assertThat(
                        detection.checkRowIdExistence(
                                baseEntries, deltaEntries, 100L, Snapshot.CommitKind.APPEND))
                .isEmpty();
    }

    @Test
    void testCheckRowIdExistenceSkipsWhenNextRowIdNull() {
        ConflictDetection detection = createConflictDetection();

        List<SimpleFileEntry> baseEntries = new ArrayList<>();
        List<SimpleFileEntry> deltaEntries = new ArrayList<>();
        deltaEntries.add(createFileEntryWithRowId("p1", ADD, 0L, 100L));

        assertThat(
                        detection.checkRowIdExistence(
                                baseEntries, deltaEntries, null, Snapshot.CommitKind.APPEND))
                .isEmpty();
    }

    @Test
    void testCheckRowIdExistenceCompactAllowsAdjacentNormalRanges() {
        ConflictDetection detection = createConflictDetection();

        List<SimpleFileEntry> baseEntries =
                Arrays.asList(
                        createFileEntryWithRowId("f1", ADD, 0L, 2L),
                        createFileEntryWithRowId("f2", ADD, 2L, 2L));
        List<SimpleFileEntry> deltaEntries =
                Collections.singletonList(createFileEntryWithRowId("compacted", ADD, 0L, 4L));

        assertThat(
                        detection.checkRowIdExistence(
                                baseEntries, deltaEntries, 4L, Snapshot.CommitKind.COMPACT))
                .isEmpty();
    }

    @Test
    void testCheckRowIdExistenceCompactAllowsBlobAcrossAdjacentNormalRanges() {
        ConflictDetection detection = createConflictDetection();

        List<SimpleFileEntry> baseEntries =
                Arrays.asList(
                        createFileEntryWithRowId("f1", ADD, 0L, 2L),
                        createFileEntryWithRowId("f2", ADD, 2L, 2L));
        List<SimpleFileEntry> deltaEntries =
                Collections.singletonList(createFileEntryWithRowId("compacted.blob", ADD, 0L, 4L));

        assertThat(
                        detection.checkRowIdExistence(
                                baseEntries, deltaEntries, 4L, Snapshot.CommitKind.COMPACT))
                .isEmpty();
    }

    @Test
    void testCheckRowIdExistenceCompactRejectsStaleRangeAfterReassign() {
        ConflictDetection detection = createConflictDetection();

        List<SimpleFileEntry> baseEntries =
                Arrays.asList(
                        createFileEntryWithRowId("f1", ADD, 10L, 2L),
                        createFileEntryWithRowId("f2", ADD, 12L, 2L));
        List<SimpleFileEntry> deltaEntries =
                Collections.singletonList(createFileEntryWithRowId("compacted", ADD, 0L, 4L));

        Optional<RuntimeException> result =
                detection.checkRowIdExistence(
                        baseEntries, deltaEntries, 14L, Snapshot.CommitKind.COMPACT);
        assertThat(result).isPresent();
        assertThat(result.get()).hasMessageContaining("Row ID existence conflict");
    }

    @Test
    void testCheckRowIdExistenceCompactDoesNotMergeAcrossPartitions() {
        ConflictDetection detection = createConflictDetection();
        BinaryRow partition0 = BinaryRow.singleColumn(0);
        BinaryRow partition1 = BinaryRow.singleColumn(1);

        List<SimpleFileEntry> baseEntries =
                Arrays.asList(
                        createFileEntryWithRowId("f1", ADD, partition0, 0, 0L, 2L),
                        createFileEntryWithRowId("f2", ADD, partition1, 0, 2L, 2L));
        List<SimpleFileEntry> deltaEntries =
                Collections.singletonList(
                        createFileEntryWithRowId("compacted", ADD, partition0, 0, 0L, 4L));

        Optional<RuntimeException> result =
                detection.checkRowIdExistence(
                        baseEntries, deltaEntries, 4L, Snapshot.CommitKind.COMPACT);
        assertThat(result).isPresent();
        assertThat(result.get()).hasMessageContaining("Row ID existence conflict");
    }

    @Test
    void testCheckRowIdRangeConflictsReportsDedicatedFileSpanningDataFiles() {
        ConflictDetection detection = createConflictDetection();

        Optional<RuntimeException> exception =
                detection.checkConflicts(
                        snapshot(1),
                        Arrays.asList(
                                createFileEntryWithRowId("f1", ADD, 0L, 2L),
                                createFileEntryWithRowId("f2", ADD, 2L, 2L)),
                        Collections.singletonList(createFileEntryWithRowId("p1.blob", ADD, 0L, 4L)),
                        Collections.emptyList(),
                        null,
                        Snapshot.CommitKind.COMPACT);

        assertThat(exception).isPresent();
        assertThat(exception.get())
                .hasMessageContaining("dedicated file")
                .hasMessageContaining("p1.blob")
                .hasMessageContaining("spans multiple data file ranges")
                .hasMessageContaining("f1")
                .hasMessageContaining("f2");
    }

    @Test
    void testCheckRowIdRangeConflictsAllowsAdjacentDataFiles() {
        ConflictDetection detection = createConflictDetection();

        Optional<RuntimeException> exception =
                detection.checkConflicts(
                        snapshot(1),
                        Arrays.asList(
                                createFileEntryWithRowId("f1", ADD, 0L, 2L),
                                createFileEntryWithRowId("f2", ADD, 2L, 2L)),
                        Collections.emptyList(),
                        Collections.emptyList(),
                        null,
                        Snapshot.CommitKind.COMPACT);

        assertThat(exception).isEmpty();
    }

    @Test
    void testCheckRowIdRangeConflictsAllowsDedicatedFileCoveredByOneDataFile() {
        ConflictDetection detection = createConflictDetection();

        Optional<RuntimeException> exception =
                detection.checkConflicts(
                        snapshot(1),
                        Collections.singletonList(createFileEntryWithRowId("f1", ADD, 0L, 4L)),
                        Collections.singletonList(createFileEntryWithRowId("p1.blob", ADD, 1L, 2L)),
                        Collections.emptyList(),
                        null,
                        Snapshot.CommitKind.COMPACT);

        assertThat(exception).isEmpty();
    }

    private SimpleFileEntry createFileEntryWithRowId(
            String fileName, FileKind kind, long firstRowId, long rowCount) {
        return createFileEntryWithRowId(fileName, kind, EMPTY_ROW, 0, firstRowId, rowCount);
    }

    private SimpleFileEntry createFileEntryWithRowId(
            String fileName,
            FileKind kind,
            BinaryRow partition,
            int bucket,
            long firstRowId,
            long rowCount) {
        return new SimpleFileEntry(
                kind,
                partition,
                bucket,
                1,
                0,
                fileName,
                Collections.emptyList(),
                null,
                EMPTY_ROW,
                EMPTY_ROW,
                null,
                rowCount,
                firstRowId);
    }

    @Test
    void testCheckGlobalIndexRowIdExistenceNoConflict() {
        ConflictDetection detection = createConflictDetection();

        Optional<RuntimeException> exception =
                detection.checkConflicts(
                        snapshot(1),
                        Arrays.asList(
                                createFileEntryWithRowId("f1", ADD, 0L, 100L),
                                createFileEntryWithRowId("f2", ADD, 100L, 50L)),
                        Collections.emptyList(),
                        Collections.singletonList(
                                createGlobalIndexEntry("idx", ADD, BinaryRow.EMPTY_ROW, 0, 149)),
                        null,
                        Snapshot.CommitKind.APPEND);

        assertThat(exception).isNotPresent();
    }

    @Test
    void testCheckGlobalIndexRowIdExistenceBaseFileRemoved() {
        ConflictDetection detection = createConflictDetection();

        Optional<RuntimeException> exception =
                detection.checkConflicts(
                        snapshot(1),
                        Collections.singletonList(createFileEntryWithRowId("f1", ADD, 0L, 100L)),
                        Collections.emptyList(),
                        Collections.singletonList(
                                createGlobalIndexEntry("idx", ADD, BinaryRow.EMPTY_ROW, 0, 149)),
                        null,
                        Snapshot.CommitKind.APPEND);

        assertThat(exception).isPresent();
        assertThat(exception.get())
                .hasMessageContaining("Global index row ID existence conflict")
                .hasMessageContaining("idx")
                .hasMessageContaining("[0, 149]");
    }

    @Test
    void testCheckGlobalIndexRowIdExistenceByPartitionAndBucket() {
        ConflictDetection detection = createConflictDetection();
        BinaryRow partition0 = BinaryRow.singleColumn(0);
        BinaryRow partition1 = BinaryRow.singleColumn(1);

        Optional<RuntimeException> exception =
                detection.checkConflicts(
                        snapshot(1),
                        Collections.singletonList(
                                createFileEntryWithRowId("f1", ADD, partition1, 0, 0L, 150L)),
                        Collections.emptyList(),
                        Collections.singletonList(
                                createGlobalIndexEntry("idx", ADD, partition0, 0, 0, 149)),
                        null,
                        Snapshot.CommitKind.APPEND);

        assertThat(exception).isPresent();

        exception =
                detection.checkConflicts(
                        snapshot(1),
                        Collections.singletonList(
                                createFileEntryWithRowId("f1", ADD, partition0, 1, 0L, 150L)),
                        Collections.emptyList(),
                        Collections.singletonList(
                                createGlobalIndexEntry("idx", ADD, partition0, 0, 0, 149)),
                        null,
                        Snapshot.CommitKind.APPEND);

        assertThat(exception).isPresent();
    }

    @Test
    void testCheckGlobalIndexRowIdExistenceSkipsDeleteIndexEntry() {
        ConflictDetection detection = createConflictDetection();

        Optional<RuntimeException> exception =
                detection.checkConflicts(
                        snapshot(1),
                        Collections.emptyList(),
                        Collections.emptyList(),
                        Collections.singletonList(
                                createGlobalIndexEntry("idx", DELETE, BinaryRow.EMPTY_ROW, 0, 149)),
                        null,
                        Snapshot.CommitKind.APPEND);

        assertThat(exception).isNotPresent();
    }

    private ConflictDetection createConflictDetection() {
        return new ConflictDetection(
                "test-table",
                "test-user",
                RowType.of(),
                null,
                null,
                BucketMode.HASH_FIXED,
                false,
                true,
                false,
                null,
                null,
                null);
    }

    private Snapshot snapshot(long id) {
        return new Snapshot(
                id,
                0,
                null,
                null,
                null,
                null,
                null,
                null,
                null,
                "commit-user",
                id,
                Snapshot.CommitKind.APPEND,
                id,
                0,
                0,
                null,
                null,
                null,
                null,
                null,
                null);
    }
}
